fb-slides 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -0
- package/README.md +47 -10
- package/lib/assets.mjs +227 -0
- package/lib/config.mjs +11 -2
- package/lib/dev.mjs +5 -4
- package/lib/edit.mjs +15 -2
- package/package.json +1 -1
- package/runtime/assets.js +556 -0
- package/runtime/edit.js +65 -15
- package/runtime/snippets.js +198 -69
- package/runtime/syntax.js +64 -0
- package/runtime/theme.base.css +470 -10
- package/templates/starter/assets/screen_2026_08_05_00_24_16.png +0 -0
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Dropping files into assets/ — the other half of the edit drawer.
|
|
3
|
+
//
|
|
4
|
+
// While the drawer is open, dragging files anywhere over the deck raises a
|
|
5
|
+
// sheet of folders: assets/ itself, every subfolder it already has, and one
|
|
6
|
+
// that asks for a new name. Dropping on one saves the files there — the folder
|
|
7
|
+
// is created if it is new — and the drawer lists what landed, each row a path
|
|
8
|
+
// with two buttons: copy it, or write the markup for it into the slide.
|
|
9
|
+
//
|
|
10
|
+
// The list under the folder picker is that folder, as it is on disk: what was
|
|
11
|
+
// just dropped and what has been there since last month, each row a path to
|
|
12
|
+
// copy, to write into the slide, or to delete. The paths are relative, so they
|
|
13
|
+
// work in dev and in the built deck alike. Deleting asks for the word to be
|
|
14
|
+
// typed — it is the only thing here a reload cannot undo.
|
|
15
|
+
//
|
|
16
|
+
// Only the dev server answers /api/assets, so on a static host the sheet never
|
|
17
|
+
// appears — the drawer it belongs to is not there either.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
const API_HEADERS = { 'x-fb-slides': '1' };
|
|
21
|
+
|
|
22
|
+
// The folder target that asks for a name instead of being one: `*` is not a
|
|
23
|
+
// character any folder here may carry, so it can never collide with a real one.
|
|
24
|
+
const NEW = '*new*';
|
|
25
|
+
|
|
26
|
+
// Typed, not clicked: deleting is the one thing in this drawer that leaves no
|
|
27
|
+
// way back, and a button alone is too easy to hit on the way to something else.
|
|
28
|
+
const WORD = 'CONFIRM';
|
|
29
|
+
|
|
30
|
+
const IMAGE = /\.(png|jpe?g|gif|webp|avif|svg|bmp|ico)$/i;
|
|
31
|
+
const VIDEO = /\.(mp4|webm|mov|m4v|ogv)$/i;
|
|
32
|
+
const AUDIO = /\.(mp3|m4a|wav|ogg|aac|flac)$/i;
|
|
33
|
+
|
|
34
|
+
// What the slide would say about a file of this kind — the block picker's own
|
|
35
|
+
// markup, with the path already in it. Anything else becomes a plain link: a
|
|
36
|
+
// PDF or a zip is something to hand out, not something to render.
|
|
37
|
+
const blockFor = (path, name) => {
|
|
38
|
+
if (IMAGE.test(path)) return ``;
|
|
39
|
+
if (VIDEO.test(path)) {
|
|
40
|
+
return (
|
|
41
|
+
`<video src="${path}" controls muted playsinline preload="metadata"\n` +
|
|
42
|
+
` style="width: 78%; aspect-ratio: 16 / 9;"></video>`
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
if (AUDIO.test(path)) return `<audio src="${path}" controls></audio>`;
|
|
46
|
+
return `[${name}](${path})`;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const DROP_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
|
|
50
|
+
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
51
|
+
<path d="M12 15V4" /><path d="M8 8l4-4 4 4" />
|
|
52
|
+
<path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3" />
|
|
53
|
+
</svg>`;
|
|
54
|
+
|
|
55
|
+
const COPY_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
|
|
56
|
+
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
57
|
+
<rect x="9" y="9" width="11" height="11" rx="2" />
|
|
58
|
+
<path d="M5 15H4a1 1 0 0 1-1-1V5a2 2 0 0 1 2-2h9a1 1 0 0 1 1 1v1" />
|
|
59
|
+
</svg>`;
|
|
60
|
+
|
|
61
|
+
const INSERT_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
|
|
62
|
+
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
63
|
+
<path d="M20 5v6a3 3 0 0 1-3 3H5" /><path d="M9 10l-4 4 4 4" />
|
|
64
|
+
</svg>`;
|
|
65
|
+
|
|
66
|
+
const CLOSE_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
|
|
67
|
+
stroke-linecap="round" aria-hidden="true">
|
|
68
|
+
<path d="M6 6l12 12" /><path d="M18 6L6 18" />
|
|
69
|
+
</svg>`;
|
|
70
|
+
|
|
71
|
+
const TRASH_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
|
|
72
|
+
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
73
|
+
<path d="M4 7h16" /><path d="M10 5h4" />
|
|
74
|
+
<path d="M6.5 7l.8 12a1 1 0 0 0 1 1h7.4a1 1 0 0 0 1-1l.8-12" />
|
|
75
|
+
</svg>`;
|
|
76
|
+
|
|
77
|
+
const hasFiles = (event) => [...(event.dataTransfer?.types ?? [])].includes('Files');
|
|
78
|
+
|
|
79
|
+
// A 511-byte SVG rounding to `0 KB` is a row that looks like a broken file.
|
|
80
|
+
const bytes = (size) =>
|
|
81
|
+
size < 1024
|
|
82
|
+
? `${size} B`
|
|
83
|
+
: size < 1024 * 1024
|
|
84
|
+
? `${Math.round(size / 1024)} KB`
|
|
85
|
+
: `${(size / 1048576).toFixed(1)} MB`;
|
|
86
|
+
|
|
87
|
+
// One file, streamed. XHR rather than fetch for the one thing fetch cannot do:
|
|
88
|
+
// tell you how far a 60 MB screen recording has got.
|
|
89
|
+
const upload = (file, dir, onProgress) =>
|
|
90
|
+
new Promise((done, fail) => {
|
|
91
|
+
const request = new XMLHttpRequest();
|
|
92
|
+
request.open('POST', '/api/assets');
|
|
93
|
+
request.setRequestHeader('x-fb-slides', '1');
|
|
94
|
+
// A filename is not latin-1 and a header is: encoded here, decoded there.
|
|
95
|
+
request.setRequestHeader('x-fb-name', encodeURIComponent(file.name));
|
|
96
|
+
request.setRequestHeader('x-fb-dir', encodeURIComponent(dir));
|
|
97
|
+
request.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
|
|
98
|
+
request.upload.addEventListener('progress', (event) => {
|
|
99
|
+
if (event.lengthComputable) onProgress(event.loaded / event.total);
|
|
100
|
+
});
|
|
101
|
+
request.addEventListener('load', () => {
|
|
102
|
+
let answer = {};
|
|
103
|
+
try {
|
|
104
|
+
answer = JSON.parse(request.responseText);
|
|
105
|
+
} catch {
|
|
106
|
+
/* a body that is not JSON is a failure whatever it says */
|
|
107
|
+
}
|
|
108
|
+
if (request.status === 200 && answer.path) done(answer);
|
|
109
|
+
else fail(new Error(answer.error ?? `the server said ${request.status}`));
|
|
110
|
+
});
|
|
111
|
+
request.addEventListener('error', () => fail(new Error('the server is not answering')));
|
|
112
|
+
request.send(file);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// `paste` writes a block into the slide the way the picker does; `active` says
|
|
117
|
+
// whether the drawer is open, which is the only time a drop means this.
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
export const createAssets = ({ paste, active }) => {
|
|
121
|
+
// ---- the drawer panel -------------------------------------------------
|
|
122
|
+
|
|
123
|
+
const element = document.createElement('div');
|
|
124
|
+
element.className = 'deck-assets';
|
|
125
|
+
element.hidden = true;
|
|
126
|
+
element.innerHTML = `
|
|
127
|
+
<header>
|
|
128
|
+
<span class="deck-assets-title">Assets</span>
|
|
129
|
+
<button type="button" class="deck-assets-close" aria-label="Close">${CLOSE_ICON}</button>
|
|
130
|
+
</header>
|
|
131
|
+
<div class="deck-assets-where">
|
|
132
|
+
<label for="deck-assets-dir">folder</label>
|
|
133
|
+
<select id="deck-assets-dir" class="deck-assets-dir"></select>
|
|
134
|
+
</div>
|
|
135
|
+
<div class="deck-assets-name-row" hidden>
|
|
136
|
+
<input class="deck-assets-new" type="text" spellcheck="false" autocomplete="off"
|
|
137
|
+
placeholder="new/folder" aria-label="New folder" />
|
|
138
|
+
<button type="button" class="deck-assets-go">Save here</button>
|
|
139
|
+
</div>
|
|
140
|
+
<p class="deck-assets-waiting" hidden></p>
|
|
141
|
+
<button type="button" class="deck-assets-zone">
|
|
142
|
+
Drop files anywhere on the deck<small>— or click to choose them</small>
|
|
143
|
+
</button>
|
|
144
|
+
<input type="file" class="deck-assets-input" multiple hidden />
|
|
145
|
+
<ul class="deck-assets-list"></ul>
|
|
146
|
+
<div class="deck-assets-confirm" hidden>
|
|
147
|
+
<p>Delete <code></code> from disk?<br />There is no undo, and any slide
|
|
148
|
+
pointing at it will come up empty.</p>
|
|
149
|
+
<input class="deck-assets-word" type="text" spellcheck="false" autocomplete="off"
|
|
150
|
+
placeholder="${WORD}" aria-label="Type ${WORD} to delete" />
|
|
151
|
+
<div>
|
|
152
|
+
<button type="button" class="deck-assets-cancel">Cancel</button>
|
|
153
|
+
<button type="button" class="deck-assets-confirm-delete" disabled>Delete</button>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
`;
|
|
157
|
+
|
|
158
|
+
const select = element.querySelector('.deck-assets-dir');
|
|
159
|
+
const nameRow = element.querySelector('.deck-assets-name-row');
|
|
160
|
+
const nameInput = element.querySelector('.deck-assets-new');
|
|
161
|
+
const goButton = element.querySelector('.deck-assets-go');
|
|
162
|
+
const waiting = element.querySelector('.deck-assets-waiting');
|
|
163
|
+
const zone = element.querySelector('.deck-assets-zone');
|
|
164
|
+
const fileInput = element.querySelector('.deck-assets-input');
|
|
165
|
+
const list = element.querySelector('.deck-assets-list');
|
|
166
|
+
const confirmBox = element.querySelector('.deck-assets-confirm');
|
|
167
|
+
const wordInput = confirmBox.querySelector('.deck-assets-word');
|
|
168
|
+
const confirmButton = confirmBox.querySelector('.deck-assets-confirm-delete');
|
|
169
|
+
|
|
170
|
+
// ---- the full-page sheet ----------------------------------------------
|
|
171
|
+
|
|
172
|
+
const overlay = document.createElement('div');
|
|
173
|
+
overlay.id = 'deck-drop';
|
|
174
|
+
overlay.hidden = true;
|
|
175
|
+
overlay.innerHTML = `
|
|
176
|
+
<div class="deck-drop-sheet">
|
|
177
|
+
<h2>Drop into <code class="deck-drop-root">assets/</code></h2>
|
|
178
|
+
<ul class="deck-drop-targets"></ul>
|
|
179
|
+
</div>
|
|
180
|
+
`;
|
|
181
|
+
const targets = overlay.querySelector('.deck-drop-targets');
|
|
182
|
+
const rootLabel = overlay.querySelector('.deck-drop-root');
|
|
183
|
+
|
|
184
|
+
// ---- state ------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
let root = 'assets'; // the folder as the browser writes it
|
|
187
|
+
let dirs = []; // its subfolders, as the server last listed them
|
|
188
|
+
let chosen = ''; // where the next drop goes, remembered between drops
|
|
189
|
+
let pending = null; // files waiting for a folder that has yet to be named
|
|
190
|
+
let asking = null; // the path the delete question is standing over
|
|
191
|
+
let files = []; // the chosen folder, as the server last listed it
|
|
192
|
+
let failures = []; // uploads that did not land, kept under the listing
|
|
193
|
+
const fresh = new Set(); // what this session put there — worth pointing at
|
|
194
|
+
|
|
195
|
+
// The root, what the server last listed, and — however that listing raced
|
|
196
|
+
// with the upload that made it — the folder this drop actually went to.
|
|
197
|
+
const folders = () => {
|
|
198
|
+
const all = new Set(['', ...dirs]);
|
|
199
|
+
if (chosen) all.add(chosen);
|
|
200
|
+
return [...all];
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const drawSelect = () => {
|
|
204
|
+
select.innerHTML = folders()
|
|
205
|
+
.map((dir) => `<option value="${dir}">${root}/${dir ? `${dir}/` : ''}</option>`)
|
|
206
|
+
.join('');
|
|
207
|
+
select.insertAdjacentHTML('beforeend', `<option value="${NEW}">+ new folder…</option>`);
|
|
208
|
+
select.value = pending ? NEW : folders().includes(chosen) ? chosen : '';
|
|
209
|
+
nameRow.hidden = select.value !== NEW;
|
|
210
|
+
goButton.hidden = !pending;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const drawTargets = () => {
|
|
214
|
+
rootLabel.textContent = `${root}/`;
|
|
215
|
+
targets.innerHTML = folders()
|
|
216
|
+
.map(
|
|
217
|
+
(dir) =>
|
|
218
|
+
`<li data-dir="${dir}"${dir === chosen ? ' class="is-last"' : ''}>` +
|
|
219
|
+
`${dir ? `${dir}/` : `${root}/`}</li>`,
|
|
220
|
+
)
|
|
221
|
+
.join('');
|
|
222
|
+
targets.insertAdjacentHTML('beforeend', `<li data-dir="${NEW}" class="is-new">+ new folder…</li>`);
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// The folder as it is right now — its subfolders to aim at, and its files to
|
|
226
|
+
// list. Called on open, on picking another folder, and after every write.
|
|
227
|
+
const refresh = async () => {
|
|
228
|
+
const where = pending ? '' : chosen;
|
|
229
|
+
try {
|
|
230
|
+
const response = await fetch(`/api/assets?dir=${encodeURIComponent(where)}`, {
|
|
231
|
+
headers: API_HEADERS,
|
|
232
|
+
cache: 'no-cache',
|
|
233
|
+
});
|
|
234
|
+
if (response.ok) {
|
|
235
|
+
const answer = await response.json();
|
|
236
|
+
root = answer.dir ?? root;
|
|
237
|
+
dirs = Array.isArray(answer.dirs) ? answer.dirs : [];
|
|
238
|
+
files = Array.isArray(answer.files) ? answer.files : [];
|
|
239
|
+
}
|
|
240
|
+
} catch {
|
|
241
|
+
/* no listing: assets/ itself is always there to drop into */
|
|
242
|
+
files = [];
|
|
243
|
+
}
|
|
244
|
+
drawSelect();
|
|
245
|
+
drawTargets();
|
|
246
|
+
drawFiles();
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
// ---- open and close ---------------------------------------------------
|
|
250
|
+
|
|
251
|
+
const open = () => {
|
|
252
|
+
element.hidden = false;
|
|
253
|
+
refresh();
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const closeConfirm = () => {
|
|
257
|
+
asking = null;
|
|
258
|
+
confirmBox.hidden = true;
|
|
259
|
+
wordInput.value = '';
|
|
260
|
+
confirmButton.disabled = true;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const close = () => {
|
|
264
|
+
element.hidden = true;
|
|
265
|
+
pending = null;
|
|
266
|
+
nameRow.hidden = true;
|
|
267
|
+
waiting.hidden = true;
|
|
268
|
+
closeConfirm();
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
// ---- the rows ---------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
const copy = async (text, button) => {
|
|
274
|
+
try {
|
|
275
|
+
await navigator.clipboard.writeText(text);
|
|
276
|
+
button.classList.add('is-done');
|
|
277
|
+
setTimeout(() => button.classList.remove('is-done'), 900);
|
|
278
|
+
} catch {
|
|
279
|
+
// No clipboard permission: select the path instead, so ⌘C still works.
|
|
280
|
+
const range = document.createRange();
|
|
281
|
+
range.selectNodeContents(button.closest('li').querySelector('code'));
|
|
282
|
+
const selection = getSelection();
|
|
283
|
+
selection.removeAllRanges();
|
|
284
|
+
selection.addRange(range);
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
// The folder, one row per file: the path, and the three things worth doing
|
|
289
|
+
// with it. Rebuilt whole on every refresh — a listing is cheap, and the
|
|
290
|
+
// alternative is keeping rows in step with a folder two other things write to.
|
|
291
|
+
const drawFiles = () => {
|
|
292
|
+
list.innerHTML = '';
|
|
293
|
+
|
|
294
|
+
if (!files.length && !failures.length) {
|
|
295
|
+
const empty = document.createElement('li');
|
|
296
|
+
empty.className = 'deck-assets-none';
|
|
297
|
+
empty.textContent = pending ? 'The folder will be made when you name it.' : 'Nothing in this folder yet.';
|
|
298
|
+
list.append(empty);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
for (const file of files) {
|
|
302
|
+
const row = document.createElement('li');
|
|
303
|
+
if (fresh.has(file.path)) row.classList.add('is-fresh');
|
|
304
|
+
row.innerHTML =
|
|
305
|
+
`<code></code>` +
|
|
306
|
+
`<span class="deck-assets-size"></span>` +
|
|
307
|
+
`<span class="deck-assets-acts">` +
|
|
308
|
+
`<button type="button" class="deck-assets-copy" title="Copy the path">${COPY_ICON}</button>` +
|
|
309
|
+
`<button type="button" class="deck-assets-insert" title="Write it into the slide">${INSERT_ICON}</button>` +
|
|
310
|
+
`<button type="button" class="deck-assets-trash" title="Delete the file">${TRASH_ICON}</button>` +
|
|
311
|
+
`</span>`;
|
|
312
|
+
row.querySelector('code').textContent = file.path;
|
|
313
|
+
// The drawer is narrow and a path can be long: the row truncates, and
|
|
314
|
+
// hovering it says the whole thing.
|
|
315
|
+
row.title = file.path;
|
|
316
|
+
row.querySelector('.deck-assets-size').textContent = bytes(file.size);
|
|
317
|
+
row.querySelector('.deck-assets-copy').addEventListener('click', (event) =>
|
|
318
|
+
copy(file.path, event.currentTarget),
|
|
319
|
+
);
|
|
320
|
+
row.querySelector('.deck-assets-insert').addEventListener('click', () => {
|
|
321
|
+
paste(blockFor(file.path, file.name));
|
|
322
|
+
close();
|
|
323
|
+
});
|
|
324
|
+
row.querySelector('.deck-assets-trash').addEventListener('click', () => askDelete(file.path));
|
|
325
|
+
list.append(row);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// What did not land stays under the listing rather than vanishing with the
|
|
329
|
+
// refresh that follows the upload.
|
|
330
|
+
for (const failure of failures) {
|
|
331
|
+
const row = document.createElement('li');
|
|
332
|
+
row.className = 'is-failed';
|
|
333
|
+
row.innerHTML = `<span class="deck-assets-file"></span><span class="deck-assets-pct"></span>`;
|
|
334
|
+
row.querySelector('.deck-assets-file').textContent = failure.name;
|
|
335
|
+
row.querySelector('.deck-assets-pct').textContent = failure.message;
|
|
336
|
+
list.append(row);
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
// While an upload is in flight it has a row of its own, under the listing,
|
|
341
|
+
// counting up. The refresh that follows replaces it with the real thing.
|
|
342
|
+
const addRow = (file) => {
|
|
343
|
+
const row = document.createElement('li');
|
|
344
|
+
row.innerHTML = `<span class="deck-assets-file"></span><span class="deck-assets-pct"></span>`;
|
|
345
|
+
row.querySelector('.deck-assets-file').textContent = file.name;
|
|
346
|
+
const pct = row.querySelector('.deck-assets-pct');
|
|
347
|
+
pct.textContent = bytes(file.size);
|
|
348
|
+
list.append(row);
|
|
349
|
+
row.scrollIntoView({ block: 'nearest' });
|
|
350
|
+
return {
|
|
351
|
+
progress: (share) => {
|
|
352
|
+
pct.textContent = `${Math.round(share * 100)}%`;
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
// One at a time: a folder of screenshots dropped at once should not open ten
|
|
358
|
+
// sockets, and the rows then fill in the order they were dropped.
|
|
359
|
+
const send = async (dropped, dir) => {
|
|
360
|
+
if (!dropped.length) return;
|
|
361
|
+
chosen = dir;
|
|
362
|
+
failures = [];
|
|
363
|
+
open();
|
|
364
|
+
for (const file of dropped) {
|
|
365
|
+
const row = addRow(file);
|
|
366
|
+
try {
|
|
367
|
+
const answer = await upload(file, dir, row.progress);
|
|
368
|
+
// The folder as the server spells it — `talk 2026` was typed, and what
|
|
369
|
+
// exists on disk is `talk-2026`. The list should say the latter.
|
|
370
|
+
chosen = answer.dir;
|
|
371
|
+
fresh.add(answer.path);
|
|
372
|
+
} catch (error) {
|
|
373
|
+
failures.push({ name: file.name, message: error.message });
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
await refresh();
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const askDelete = (path) => {
|
|
380
|
+
asking = path;
|
|
381
|
+
confirmBox.querySelector('code').textContent = path;
|
|
382
|
+
wordInput.value = '';
|
|
383
|
+
confirmButton.disabled = true;
|
|
384
|
+
confirmBox.hidden = false;
|
|
385
|
+
wordInput.focus();
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
const doDelete = async () => {
|
|
389
|
+
if (!asking || confirmButton.disabled) return;
|
|
390
|
+
const path = asking;
|
|
391
|
+
confirmButton.disabled = true;
|
|
392
|
+
try {
|
|
393
|
+
const response = await fetch(`/api/assets?path=${encodeURIComponent(path)}`, {
|
|
394
|
+
method: 'DELETE',
|
|
395
|
+
headers: API_HEADERS,
|
|
396
|
+
});
|
|
397
|
+
if (!response.ok) throw new Error((await response.json()).error ?? response.status);
|
|
398
|
+
fresh.delete(path);
|
|
399
|
+
closeConfirm();
|
|
400
|
+
refresh();
|
|
401
|
+
} catch (error) {
|
|
402
|
+
confirmBox.querySelector('code').textContent = error.message;
|
|
403
|
+
confirmButton.disabled = false;
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
// A drop that named no folder yet: hold the files, ask, and send on the answer.
|
|
408
|
+
const askName = (dropped) => {
|
|
409
|
+
pending = dropped;
|
|
410
|
+
open();
|
|
411
|
+
drawSelect();
|
|
412
|
+
nameRow.hidden = false;
|
|
413
|
+
goButton.hidden = false;
|
|
414
|
+
waiting.hidden = false;
|
|
415
|
+
waiting.textContent = `${dropped.length} file${dropped.length === 1 ? '' : 's'} waiting for a folder name.`;
|
|
416
|
+
nameInput.focus();
|
|
417
|
+
nameInput.select();
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
const confirmName = () => {
|
|
421
|
+
const dir = nameInput.value.trim().replace(/^\/+|\/+$/g, '');
|
|
422
|
+
if (!dir) return nameInput.focus();
|
|
423
|
+
const held = pending;
|
|
424
|
+
pending = null;
|
|
425
|
+
nameInput.value = '';
|
|
426
|
+
nameRow.hidden = true;
|
|
427
|
+
goButton.hidden = true;
|
|
428
|
+
waiting.hidden = true;
|
|
429
|
+
if (held) send(held, dir);
|
|
430
|
+
else {
|
|
431
|
+
chosen = dir;
|
|
432
|
+
refresh();
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
const drop = (dropped, dir) => {
|
|
437
|
+
if (!dropped.length) return;
|
|
438
|
+
if (dir === NEW) askName(dropped);
|
|
439
|
+
else send(dropped, dir);
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
// ---- wiring: the panel -------------------------------------------------
|
|
443
|
+
|
|
444
|
+
element.querySelector('.deck-assets-close').addEventListener('click', close);
|
|
445
|
+
|
|
446
|
+
select.addEventListener('change', () => {
|
|
447
|
+
if (select.value === NEW) {
|
|
448
|
+
nameRow.hidden = false;
|
|
449
|
+
nameInput.focus();
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
nameRow.hidden = true;
|
|
453
|
+
chosen = select.value;
|
|
454
|
+
failures = [];
|
|
455
|
+
refresh();
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
wordInput.addEventListener('input', () => {
|
|
459
|
+
confirmButton.disabled = wordInput.value.trim() !== WORD;
|
|
460
|
+
});
|
|
461
|
+
wordInput.addEventListener('keydown', (event) => {
|
|
462
|
+
if (event.key !== 'Enter') return;
|
|
463
|
+
event.preventDefault();
|
|
464
|
+
doDelete();
|
|
465
|
+
});
|
|
466
|
+
confirmBox.querySelector('.deck-assets-cancel').addEventListener('click', closeConfirm);
|
|
467
|
+
confirmButton.addEventListener('click', doDelete);
|
|
468
|
+
|
|
469
|
+
goButton.addEventListener('click', confirmName);
|
|
470
|
+
nameInput.addEventListener('keydown', (event) => {
|
|
471
|
+
if (event.key !== 'Enter') return;
|
|
472
|
+
event.preventDefault();
|
|
473
|
+
confirmName();
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
zone.addEventListener('click', () => {
|
|
477
|
+
if (select.value === NEW && !nameInput.value.trim()) return nameInput.focus();
|
|
478
|
+
fileInput.click();
|
|
479
|
+
});
|
|
480
|
+
fileInput.addEventListener('change', () => {
|
|
481
|
+
const picked = [...fileInput.files];
|
|
482
|
+
fileInput.value = '';
|
|
483
|
+
drop(picked, select.value === NEW ? nameInput.value.trim() : select.value);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
// ---- wiring: the sheet -------------------------------------------------
|
|
487
|
+
// `dragover` is the only event a drag repeats — every few milliseconds, for
|
|
488
|
+
// as long as it is over the page. So the sheet is raised by it and dropped by
|
|
489
|
+
// its absence: a watchdog restarted on each one, firing shortly after the
|
|
490
|
+
// last. Counting dragenter against dragleave is the usual way, and it is the
|
|
491
|
+
// one that goes wrong here — the sheet appears under the cursor mid-drag,
|
|
492
|
+
// which is itself a change of target, and the pair that follows does not
|
|
493
|
+
// arrive in a fixed order.
|
|
494
|
+
|
|
495
|
+
let watchdog = 0;
|
|
496
|
+
|
|
497
|
+
const show = () => {
|
|
498
|
+
clearTimeout(watchdog);
|
|
499
|
+
watchdog = setTimeout(hide, 240);
|
|
500
|
+
if (!overlay.hidden) return;
|
|
501
|
+
drawTargets();
|
|
502
|
+
overlay.hidden = false;
|
|
503
|
+
refresh();
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
const hide = () => {
|
|
507
|
+
clearTimeout(watchdog);
|
|
508
|
+
watchdog = 0;
|
|
509
|
+
overlay.hidden = true;
|
|
510
|
+
for (const target of targets.children) target.classList.remove('is-over');
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
const over = (event) => {
|
|
514
|
+
if (!active() || !hasFiles(event)) return;
|
|
515
|
+
// Without this the browser takes the drop and navigates to the file.
|
|
516
|
+
event.preventDefault();
|
|
517
|
+
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
|
|
518
|
+
show();
|
|
519
|
+
const under = event.target.closest?.('[data-dir]');
|
|
520
|
+
for (const target of targets.children) target.classList.toggle('is-over', target === under);
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
// Both, so the sheet is up on the first frame of the drag rather than on the
|
|
524
|
+
// first repeat — they do the same thing, and `show` is idempotent.
|
|
525
|
+
document.addEventListener('dragenter', over);
|
|
526
|
+
document.addEventListener('dragover', over);
|
|
527
|
+
// A drag that ends anywhere else — Escape, or a drop outside the window.
|
|
528
|
+
document.addEventListener('dragend', hide);
|
|
529
|
+
|
|
530
|
+
document.addEventListener('drop', (event) => {
|
|
531
|
+
if (!active() || !hasFiles(event)) return;
|
|
532
|
+
event.preventDefault();
|
|
533
|
+
const target = event.target.closest?.('[data-dir]');
|
|
534
|
+
hide();
|
|
535
|
+
drop([...event.dataTransfer.files], target?.dataset.dir ?? chosen);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
document.body.append(overlay);
|
|
539
|
+
|
|
540
|
+
return {
|
|
541
|
+
element,
|
|
542
|
+
open,
|
|
543
|
+
close,
|
|
544
|
+
isOpen: () => !element.hidden,
|
|
545
|
+
hideSheet: hide,
|
|
546
|
+
// One layer per press, innermost first — the drawer asks this before it
|
|
547
|
+
// decides whether Escape was meant for it.
|
|
548
|
+
escape: () => {
|
|
549
|
+
if (!confirmBox.hidden) return closeConfirm(), true;
|
|
550
|
+
if (!element.hidden) return close(), true;
|
|
551
|
+
return false;
|
|
552
|
+
},
|
|
553
|
+
};
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
export default createAssets;
|