fb-slides 0.9.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 CHANGED
@@ -7,6 +7,16 @@ this — which makes it worth being able to see what arrived.
7
7
  Entries are written as the work lands, under **Unreleased**; the release commit
8
8
  that stamps the version renames that heading to the version and its date.
9
9
 
10
+ ## 0.9.1 — 2026-09-08
11
+
12
+ - **The assets panel shows the folder, not just what you dropped.** The list
13
+ under the folder picker is now that folder as it is on disk — the screenshot
14
+ from a moment ago and the one from last month, side by side — so an asset
15
+ that was already there can be copied, written into the slide, or deleted
16
+ without going to the Finder for it. Picking another folder lists that one,
17
+ what this session put there is tinted, and a file under a kilobyte is counted
18
+ in bytes rather than rounding to `0 KB`.
19
+
10
20
  ## 0.9.0 — 2026-09-08
11
21
 
12
22
  - **The editor answers `Shift+E`, not `T`.** The drawer's shortcut moved off
package/README.md CHANGED
@@ -273,10 +273,13 @@ files are saved there; drop on **+ new folder…** and it asks for a name and
273
273
  makes it. The button next to the `+` opens the same panel for files you would
274
274
  rather pick than drag.
275
275
 
276
- Each file that lands is listed with the path to write in the slide, a button
277
- that copies it, and one that writes it for you at the cursor: `![](…)` for an
278
- image, a `<video>` with the attributes that make it behave for a clip, an
279
- `<audio>` for sound, a link for everything else. Names are folded to something
276
+ The list under the folder picker is that folder, as it is on disk: what was just
277
+ dropped and what has been there since last month. Each row carries the path to
278
+ write in the slide, a button that copies it, and one that writes it for you at
279
+ the cursor: `![](…)` for an image, a `<video>` with the attributes that make it
280
+ behave for a clip, an `<audio>` for sound, a link for everything else. Picking
281
+ another folder lists that one; what this session put there is tinted, so it
282
+ stands out among files that were already around. Names are folded to something
280
283
  a URL can carry — `Schermata città (1).PNG` becomes `Schermata-citta-1.png` —
281
284
  and nothing is ever overwritten: a second `clip.mp4` lands as `clip-2.mp4`.
282
285
 
package/lib/assets.mjs CHANGED
@@ -6,8 +6,8 @@
6
6
  // and putting the picture it points at next to it are the same job, so the
7
7
  // drawer that does one does the other.
8
8
  //
9
- // GET /api/assets → { dir, dirs } — where files land, and the subfolders
10
- // that are already there to aim at
9
+ // GET /api/assets?dir=x → { dir, dirs, at, files } — where files land, the
10
+ // subfolders to aim at, and what is in the one asked for
11
11
  // POST /api/assets → the file's own bytes, its name and folder riding in
12
12
  // headers (a filename is not latin-1, hence the encoding);
13
13
  // answers { path } — what the slide should say
@@ -103,6 +103,23 @@ const freeName = async (dir, name) => {
103
103
  return null;
104
104
  };
105
105
 
106
+ // What is in one folder, as rows the drawer can list: files only, no dotfiles,
107
+ // each carrying the path a slide would write.
108
+ const filesIn = async (path, dir, relative) => {
109
+ const entries = await readdir(path, { withFileTypes: true }).catch(() => []);
110
+ const found = [];
111
+ for (const entry of entries) {
112
+ if (!entry.isFile() || entry.name.startsWith('.')) continue;
113
+ const info = await stat(join(path, entry.name)).catch(() => null);
114
+ found.push({
115
+ name: entry.name,
116
+ size: info?.size ?? 0,
117
+ path: [dir, relative, entry.name].filter(Boolean).join('/'),
118
+ });
119
+ }
120
+ return found.sort((a, b) => a.name.localeCompare(b.name, 'en', { numeric: true }));
121
+ };
122
+
106
123
  const subfolders = async (root) => {
107
124
  const walk = async (dir, prefix, left) => {
108
125
  if (!left) return [];
@@ -157,7 +174,16 @@ const receive = async (req, target) => {
157
174
  // where that is on disk.
158
175
  export const createAssetApi = ({ dir, path: root }) => async (req, res) => {
159
176
  if (req.method === 'GET') {
160
- return json(res, 200, { dir, dirs: await subfolders(root).catch(() => []) });
177
+ // `dir` is the folder the drawer is showing — the root when it says nothing,
178
+ // and nothing at all while a new folder is being named.
179
+ const asked = new URL(req.url, 'http://localhost').searchParams.get('dir') ?? '';
180
+ const at = folderIn(root, asked);
181
+ return json(res, 200, {
182
+ dir,
183
+ dirs: await subfolders(root).catch(() => []),
184
+ at: at?.relative ?? '',
185
+ files: at ? await filesIn(at.path, dir, at.relative) : [],
186
+ });
161
187
  }
162
188
 
163
189
  if (req.method !== 'POST' && req.method !== 'DELETE') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fb-slides",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "type": "module",
5
5
  "description": "Markdown-driven reveal.js decks: live demo embeds, annotation, mermaid, and a zero-config dev server",
6
6
  "keywords": [
package/runtime/assets.js CHANGED
@@ -7,11 +7,11 @@
7
7
  // is created if it is new — and the drawer lists what landed, each row a path
8
8
  // with two buttons: copy it, or write the markup for it into the slide.
9
9
  //
10
- // Nothing here is a file browser. The list is what *this* drop produced, which
11
- // is what you need in the ten seconds after dragging a screenshot in; the paths
12
- // are relative, so they work in dev and in the built deck alike. A row can also
13
- // delete its file — the wrong screenshot, dropped a moment ago — and that one
14
- // asks for the word to be typed: it is the only thing here a reload cannot undo.
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
15
  //
16
16
  // Only the dev server answers /api/assets, so on a static host the sheet never
17
17
  // appears — the drawer it belongs to is not there either.
@@ -76,7 +76,13 @@ const TRASH_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" s
76
76
 
77
77
  const hasFiles = (event) => [...(event.dataTransfer?.types ?? [])].includes('Files');
78
78
 
79
- const bytes = (size) => (size < 1024 * 1024 ? `${Math.round(size / 1024)} KB` : `${(size / 1048576).toFixed(1)} MB`);
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`;
80
86
 
81
87
  // One file, streamed. XHR rather than fetch for the one thing fetch cannot do:
82
88
  // tell you how far a 60 MB screen recording has got.
@@ -181,7 +187,10 @@ export const createAssets = ({ paste, active }) => {
181
187
  let dirs = []; // its subfolders, as the server last listed them
182
188
  let chosen = ''; // where the next drop goes, remembered between drops
183
189
  let pending = null; // files waiting for a folder that has yet to be named
184
- let asking = null; // { path, row } — the delete question standing over the list
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
185
194
 
186
195
  // The root, what the server last listed, and — however that listing raced
187
196
  // with the upload that made it — the folder this drop actually went to.
@@ -213,21 +222,28 @@ export const createAssets = ({ paste, active }) => {
213
222
  targets.insertAdjacentHTML('beforeend', `<li data-dir="${NEW}" class="is-new">+ new folder…</li>`);
214
223
  };
215
224
 
216
- // The folder list as it is right now: a drop is often the second one, and the
217
- // folder the first one made should be there to aim at.
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.
218
227
  const refresh = async () => {
228
+ const where = pending ? '' : chosen;
219
229
  try {
220
- const response = await fetch('/api/assets', { headers: API_HEADERS, cache: 'no-cache' });
230
+ const response = await fetch(`/api/assets?dir=${encodeURIComponent(where)}`, {
231
+ headers: API_HEADERS,
232
+ cache: 'no-cache',
233
+ });
221
234
  if (response.ok) {
222
235
  const answer = await response.json();
223
236
  root = answer.dir ?? root;
224
237
  dirs = Array.isArray(answer.dirs) ? answer.dirs : [];
238
+ files = Array.isArray(answer.files) ? answer.files : [];
225
239
  }
226
240
  } catch {
227
241
  /* no listing: assets/ itself is always there to drop into */
242
+ files = [];
228
243
  }
229
244
  drawSelect();
230
245
  drawTargets();
246
+ drawFiles();
231
247
  };
232
248
 
233
249
  // ---- open and close ---------------------------------------------------
@@ -269,6 +285,60 @@ export const createAssets = ({ paste, active }) => {
269
285
  }
270
286
  };
271
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.
272
342
  const addRow = (file) => {
273
343
  const row = document.createElement('li');
274
344
  row.innerHTML = `<span class="deck-assets-file"></span><span class="deck-assets-pct"></span>`;
@@ -277,64 +347,37 @@ export const createAssets = ({ paste, active }) => {
277
347
  pct.textContent = bytes(file.size);
278
348
  list.append(row);
279
349
  row.scrollIntoView({ block: 'nearest' });
280
-
281
350
  return {
282
351
  progress: (share) => {
283
352
  pct.textContent = `${Math.round(share * 100)}%`;
284
353
  },
285
- failed: (message) => {
286
- row.classList.add('is-failed');
287
- pct.textContent = message;
288
- },
289
- done: ({ path, name }) => {
290
- row.classList.add('is-done');
291
- row.innerHTML =
292
- `<code></code>` +
293
- `<span class="deck-assets-acts">` +
294
- `<button type="button" class="deck-assets-copy" title="Copy the path">${COPY_ICON}</button>` +
295
- `<button type="button" class="deck-assets-insert" title="Write it into the slide">${INSERT_ICON}</button>` +
296
- `<button type="button" class="deck-assets-trash" title="Delete the file">${TRASH_ICON}</button>` +
297
- `</span>`;
298
- row.querySelector('code').textContent = path;
299
- row.querySelector('.deck-assets-copy').addEventListener('click', (event) =>
300
- copy(path, event.currentTarget),
301
- );
302
- row.querySelector('.deck-assets-insert').addEventListener('click', () => {
303
- paste(blockFor(path, name));
304
- close();
305
- });
306
- row.querySelector('.deck-assets-trash').addEventListener('click', () => askDelete(path, row));
307
- },
308
354
  };
309
355
  };
310
356
 
311
357
  // One at a time: a folder of screenshots dropped at once should not open ten
312
358
  // sockets, and the rows then fill in the order they were dropped.
313
- const send = async (files, dir) => {
314
- if (!files.length) return;
359
+ const send = async (dropped, dir) => {
360
+ if (!dropped.length) return;
315
361
  chosen = dir;
362
+ failures = [];
316
363
  open();
317
- for (const file of files) {
364
+ for (const file of dropped) {
318
365
  const row = addRow(file);
319
366
  try {
320
367
  const answer = await upload(file, dir, row.progress);
321
- row.done(answer);
322
368
  // The folder as the server spells it — `talk 2026` was typed, and what
323
369
  // exists on disk is `talk-2026`. The list should say the latter.
324
370
  chosen = answer.dir;
325
- if (answer.dir && !dirs.includes(answer.dir)) {
326
- dirs = [...dirs, answer.dir].sort((a, b) => a.localeCompare(b, 'en', { numeric: true }));
327
- }
328
- drawSelect();
329
- drawTargets();
371
+ fresh.add(answer.path);
330
372
  } catch (error) {
331
- row.failed(error.message);
373
+ failures.push({ name: file.name, message: error.message });
332
374
  }
333
375
  }
376
+ await refresh();
334
377
  };
335
378
 
336
- const askDelete = (path, row) => {
337
- asking = { path, row };
379
+ const askDelete = (path) => {
380
+ asking = path;
338
381
  confirmBox.querySelector('code').textContent = path;
339
382
  wordInput.value = '';
340
383
  confirmButton.disabled = true;
@@ -344,7 +387,7 @@ export const createAssets = ({ paste, active }) => {
344
387
 
345
388
  const doDelete = async () => {
346
389
  if (!asking || confirmButton.disabled) return;
347
- const { path, row } = asking;
390
+ const path = asking;
348
391
  confirmButton.disabled = true;
349
392
  try {
350
393
  const response = await fetch(`/api/assets?path=${encodeURIComponent(path)}`, {
@@ -352,10 +395,8 @@ export const createAssets = ({ paste, active }) => {
352
395
  headers: API_HEADERS,
353
396
  });
354
397
  if (!response.ok) throw new Error((await response.json()).error ?? response.status);
355
- row.remove();
398
+ fresh.delete(path);
356
399
  closeConfirm();
357
- // The folder may have been the file's only reason to exist; the list says
358
- // what is there now either way.
359
400
  refresh();
360
401
  } catch (error) {
361
402
  confirmBox.querySelector('code').textContent = error.message;
@@ -364,14 +405,14 @@ export const createAssets = ({ paste, active }) => {
364
405
  };
365
406
 
366
407
  // A drop that named no folder yet: hold the files, ask, and send on the answer.
367
- const askName = (files) => {
368
- pending = files;
408
+ const askName = (dropped) => {
409
+ pending = dropped;
369
410
  open();
370
411
  drawSelect();
371
412
  nameRow.hidden = false;
372
413
  goButton.hidden = false;
373
414
  waiting.hidden = false;
374
- waiting.textContent = `${files.length} file${files.length === 1 ? '' : 's'} waiting for a folder name.`;
415
+ waiting.textContent = `${dropped.length} file${dropped.length === 1 ? '' : 's'} waiting for a folder name.`;
375
416
  nameInput.focus();
376
417
  nameInput.select();
377
418
  };
@@ -379,23 +420,23 @@ export const createAssets = ({ paste, active }) => {
379
420
  const confirmName = () => {
380
421
  const dir = nameInput.value.trim().replace(/^\/+|\/+$/g, '');
381
422
  if (!dir) return nameInput.focus();
382
- const files = pending;
423
+ const held = pending;
383
424
  pending = null;
384
425
  nameInput.value = '';
385
426
  nameRow.hidden = true;
386
427
  goButton.hidden = true;
387
428
  waiting.hidden = true;
388
- if (files) send(files, dir);
429
+ if (held) send(held, dir);
389
430
  else {
390
431
  chosen = dir;
391
- drawSelect();
432
+ refresh();
392
433
  }
393
434
  };
394
435
 
395
- const drop = (files, dir) => {
396
- if (!files.length) return;
397
- if (dir === NEW) askName(files);
398
- else send(files, dir);
436
+ const drop = (dropped, dir) => {
437
+ if (!dropped.length) return;
438
+ if (dir === NEW) askName(dropped);
439
+ else send(dropped, dir);
399
440
  };
400
441
 
401
442
  // ---- wiring: the panel -------------------------------------------------
@@ -410,6 +451,8 @@ export const createAssets = ({ paste, active }) => {
410
451
  }
411
452
  nameRow.hidden = true;
412
453
  chosen = select.value;
454
+ failures = [];
455
+ refresh();
413
456
  });
414
457
 
415
458
  wordInput.addEventListener('input', () => {
@@ -435,9 +478,9 @@ export const createAssets = ({ paste, active }) => {
435
478
  fileInput.click();
436
479
  });
437
480
  fileInput.addEventListener('change', () => {
438
- const files = [...fileInput.files];
481
+ const picked = [...fileInput.files];
439
482
  fileInput.value = '';
440
- drop(files, select.value === NEW ? nameInput.value.trim() : select.value);
483
+ drop(picked, select.value === NEW ? nameInput.value.trim() : select.value);
441
484
  });
442
485
 
443
486
  // ---- wiring: the sheet -------------------------------------------------
@@ -1691,11 +1691,24 @@ body.edit-open #deck-edit { transform: none; }
1691
1691
  text-overflow: ellipsis;
1692
1692
  white-space: nowrap;
1693
1693
  }
1694
- .deck-assets-pct {
1694
+ .deck-assets-pct,
1695
+ .deck-assets-size {
1695
1696
  flex: 0 0 auto;
1696
1697
  font: 400 11px/1.5 var(--mono);
1697
1698
  color: var(--muted);
1698
1699
  }
1700
+ /* The row grows to the path and the size is pinned right of it, so a long name
1701
+ truncates rather than pushing the buttons off the edge. */
1702
+ .deck-assets-list li code { flex: 1 1 auto; }
1703
+ /* Put there in this session — the row you are most likely reaching for. */
1704
+ .deck-assets-list li.is-fresh { background: color-mix(in srgb, var(--accent) 14%, transparent); }
1705
+ .deck-assets-none {
1706
+ justify-content: center;
1707
+ padding: 14px 10px;
1708
+ font: 400 12px/1.5 var(--sans);
1709
+ color: var(--muted);
1710
+ background: none;
1711
+ }
1699
1712
  .deck-assets-list li.is-failed { background: color-mix(in srgb, #e5484d 16%, transparent); }
1700
1713
  .deck-assets-list li.is-failed .deck-assets-pct { color: #ff8589; }
1701
1714