@rmdes/indiekit-frontend 1.0.0-beta.33 → 1.0.0-beta.35
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/components/file-input/index.js +41 -0
- package/components/textarea/index.js +14 -217
- package/lib/media-browser.js +215 -0
- package/lib/serviceworker.js +7 -3
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { IndiekitError } from "@indiekit/error";
|
|
2
2
|
|
|
3
|
+
import { openMediaBrowser } from "../../lib/media-browser.js";
|
|
3
4
|
import { wrapElement } from "../../lib/utils/wrap-element.js";
|
|
4
5
|
|
|
5
6
|
export const FileInputFieldController = class extends HTMLElement {
|
|
@@ -59,6 +60,46 @@ export const FileInputFieldController = class extends HTMLElement {
|
|
|
59
60
|
const $fileInputFile =
|
|
60
61
|
this.$fileInputPicker.querySelector(`.file-input__file`);
|
|
61
62
|
$fileInputFile.addEventListener("change", (event) => this.fetch(event));
|
|
63
|
+
|
|
64
|
+
// Add "Browse media" button next to the upload button
|
|
65
|
+
if (this.endpoint) {
|
|
66
|
+
const $inputButtonGroup = this.querySelector(".input-button-group");
|
|
67
|
+
if ($inputButtonGroup) {
|
|
68
|
+
const $browseBtn = document.createElement("button");
|
|
69
|
+
$browseBtn.type = "button";
|
|
70
|
+
$browseBtn.className = "file-input__browse button button--secondary";
|
|
71
|
+
$browseBtn.textContent = "Browse media";
|
|
72
|
+
$browseBtn.addEventListener("click", () => this.browseMedia());
|
|
73
|
+
$inputButtonGroup.append($browseBtn);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Open media browser to select an existing media file
|
|
80
|
+
*/
|
|
81
|
+
browseMedia() {
|
|
82
|
+
if (this._mediaBrowserOpen) return;
|
|
83
|
+
this._mediaBrowserOpen = true;
|
|
84
|
+
|
|
85
|
+
// Determine filter type from the file input's accept attribute
|
|
86
|
+
const $fileInputFile = this.querySelector(".file-input__file");
|
|
87
|
+
const accept = $fileInputFile ? $fileInputFile.getAttribute("accept") : "";
|
|
88
|
+
let filterType = "all";
|
|
89
|
+
if (accept && accept.startsWith("image/")) filterType = "photo";
|
|
90
|
+
else if (accept && accept.startsWith("audio/")) filterType = "audio";
|
|
91
|
+
else if (accept && accept.startsWith("video/")) filterType = "video";
|
|
92
|
+
|
|
93
|
+
openMediaBrowser({
|
|
94
|
+
endpoint: this.endpoint,
|
|
95
|
+
filterType,
|
|
96
|
+
onSelect: (url) => {
|
|
97
|
+
this.$fileInputPath.value = url;
|
|
98
|
+
},
|
|
99
|
+
onClose: () => {
|
|
100
|
+
this._mediaBrowserOpen = false;
|
|
101
|
+
},
|
|
102
|
+
});
|
|
62
103
|
}
|
|
63
104
|
|
|
64
105
|
/**
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import EasyMDE from "easymde";
|
|
2
2
|
|
|
3
|
+
import { openMediaBrowser } from "../../lib/media-browser.js";
|
|
4
|
+
|
|
3
5
|
const paths = {
|
|
4
6
|
bold: "M17 30c6.1 0 10-3 10-8 0-3.5-2.7-6.3-6.5-6.5V15c3-.4 5-3 5-6 0-4.5-3.5-7-9-7H5v28h12ZM12 7h2c2.5 0 4 1 4 3 0 1.5-1.5 3-4 3h-2V7Zm0 18v-7h2.3c3.1 0 4.7 1.1 4.7 3.4 0 2.5-1.4 3.6-4.8 3.6H12Z",
|
|
5
7
|
code: "m13.5 8.5-3-3L2 14C.5 15.5.5 16.5 2 18l8.5 8.5 3-3L6 16l7.5-7.5Zm5 0 3-3L30 14c1.5 1.5 1.5 2.5 0 4l-8.5 8.5-3-3L26 16l-7.5-7.5Z",
|
|
@@ -291,224 +293,19 @@ export const TextareaFieldComponent = class extends HTMLElement {
|
|
|
291
293
|
if (this._mediaBrowserOpen) return;
|
|
292
294
|
this._mediaBrowserOpen = true;
|
|
293
295
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
const modal = document.createElement("div");
|
|
307
|
-
modal.className = "media-browser__modal";
|
|
308
|
-
|
|
309
|
-
// Header
|
|
310
|
-
const header = document.createElement("div");
|
|
311
|
-
header.className = "media-browser__header";
|
|
312
|
-
|
|
313
|
-
const title = document.createElement("h2");
|
|
314
|
-
title.className = "media-browser__title";
|
|
315
|
-
title.textContent = "Browse media";
|
|
316
|
-
header.append(title);
|
|
317
|
-
|
|
318
|
-
const closeBtn = document.createElement("button");
|
|
319
|
-
closeBtn.type = "button";
|
|
320
|
-
closeBtn.className = "media-browser__close";
|
|
321
|
-
closeBtn.setAttribute("aria-label", "Close");
|
|
322
|
-
closeBtn.textContent = "\u00D7";
|
|
323
|
-
closeBtn.addEventListener("click", close);
|
|
324
|
-
header.append(closeBtn);
|
|
325
|
-
|
|
326
|
-
// Filters
|
|
327
|
-
const filters = document.createElement("div");
|
|
328
|
-
filters.className = "media-browser__filters";
|
|
329
|
-
|
|
330
|
-
for (const filter of ["all", "photo", "audio", "video"]) {
|
|
331
|
-
const btn = document.createElement("button");
|
|
332
|
-
btn.type = "button";
|
|
333
|
-
btn.className = "media-browser__filter";
|
|
334
|
-
if (filter === "all") btn.classList.add("is-active");
|
|
335
|
-
btn.textContent = filter.charAt(0).toUpperCase() + filter.slice(1);
|
|
336
|
-
btn.dataset.filter = filter;
|
|
337
|
-
btn.addEventListener("click", () => {
|
|
338
|
-
activeFilter = filter;
|
|
339
|
-
for (const f of filters.querySelectorAll(".media-browser__filter")) {
|
|
340
|
-
f.classList.toggle("is-active", f.dataset.filter === filter);
|
|
341
|
-
}
|
|
342
|
-
renderGrid();
|
|
343
|
-
});
|
|
344
|
-
filters.append(btn);
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
// Grid
|
|
348
|
-
const grid = document.createElement("div");
|
|
349
|
-
grid.className = "media-browser__grid";
|
|
350
|
-
|
|
351
|
-
// Loading
|
|
352
|
-
const loading = document.createElement("div");
|
|
353
|
-
loading.className = "media-browser__loading";
|
|
354
|
-
loading.textContent = "Loading\u2026";
|
|
355
|
-
|
|
356
|
-
// Empty
|
|
357
|
-
const empty = document.createElement("p");
|
|
358
|
-
empty.className = "media-browser__empty";
|
|
359
|
-
empty.textContent = "No media files found.";
|
|
360
|
-
empty.hidden = true;
|
|
361
|
-
|
|
362
|
-
// Load more
|
|
363
|
-
const loadMoreBtn = document.createElement("button");
|
|
364
|
-
loadMoreBtn.type = "button";
|
|
365
|
-
loadMoreBtn.className = "media-browser__load-more";
|
|
366
|
-
loadMoreBtn.textContent = "Load more";
|
|
367
|
-
loadMoreBtn.hidden = true;
|
|
368
|
-
loadMoreBtn.addEventListener("click", () => fetchMedia());
|
|
369
|
-
|
|
370
|
-
modal.append(header, filters, grid, loading, empty, loadMoreBtn);
|
|
371
|
-
overlay.append(modal);
|
|
372
|
-
document.body.append(overlay);
|
|
373
|
-
|
|
374
|
-
// Lock body scroll
|
|
375
|
-
document.body.style.overflow = "hidden";
|
|
376
|
-
|
|
377
|
-
// Close handlers
|
|
378
|
-
overlay.addEventListener("click", (event) => {
|
|
379
|
-
if (event.target === overlay) close();
|
|
296
|
+
openMediaBrowser({
|
|
297
|
+
endpoint: this.editorEndpoint,
|
|
298
|
+
onSelect: (url, filename, isImage) => {
|
|
299
|
+
const cm = editor.codemirror;
|
|
300
|
+
const cursor = cm.getCursor();
|
|
301
|
+
const text = isImage ? `` : `[${filename}](${url})`;
|
|
302
|
+
cm.replaceRange(text, cursor);
|
|
303
|
+
},
|
|
304
|
+
onClose: () => {
|
|
305
|
+
this._mediaBrowserOpen = false;
|
|
306
|
+
editor.codemirror.focus();
|
|
307
|
+
},
|
|
380
308
|
});
|
|
381
|
-
|
|
382
|
-
const onKeyDown = (event) => {
|
|
383
|
-
if (event.key === "Escape") close();
|
|
384
|
-
};
|
|
385
|
-
document.addEventListener("keydown", onKeyDown);
|
|
386
|
-
|
|
387
|
-
function close() {
|
|
388
|
-
overlay.remove();
|
|
389
|
-
document.body.style.overflow = "";
|
|
390
|
-
document.removeEventListener("keydown", onKeyDown);
|
|
391
|
-
// Use arrow-free reference to component
|
|
392
|
-
editor.codemirror.focus();
|
|
393
|
-
// Reset flag on the component instance via closure
|
|
394
|
-
closeBrowser();
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
const closeBrowser = () => {
|
|
398
|
-
this._mediaBrowserOpen = false;
|
|
399
|
-
};
|
|
400
|
-
|
|
401
|
-
function getFilteredItems() {
|
|
402
|
-
if (activeFilter === "all") return allItems;
|
|
403
|
-
return allItems.filter((item) => {
|
|
404
|
-
const type = item["media-type"] || "";
|
|
405
|
-
return type === activeFilter;
|
|
406
|
-
});
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
function isImageType(mediaType) {
|
|
410
|
-
return mediaType === "photo";
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
function getMediaIcon(mediaType) {
|
|
414
|
-
if (!mediaType) return "\uD83D\uDCC4";
|
|
415
|
-
if (mediaType === "audio") return "\uD83C\uDFB5";
|
|
416
|
-
if (mediaType === "video") return "\uD83C\uDFAC";
|
|
417
|
-
return "\uD83D\uDCC4";
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
function getFilename(url) {
|
|
421
|
-
try {
|
|
422
|
-
return decodeURIComponent(url.split("/").pop());
|
|
423
|
-
} catch {
|
|
424
|
-
return url.split("/").pop();
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
function renderGrid() {
|
|
429
|
-
grid.replaceChildren();
|
|
430
|
-
const filtered = getFilteredItems();
|
|
431
|
-
empty.hidden = filtered.length > 0;
|
|
432
|
-
|
|
433
|
-
for (const item of filtered) {
|
|
434
|
-
const url = item.url;
|
|
435
|
-
const mediaType = item["media-type"] || "";
|
|
436
|
-
const isImage = isImageType(mediaType);
|
|
437
|
-
const filename = getFilename(url);
|
|
438
|
-
|
|
439
|
-
const tile = document.createElement("button");
|
|
440
|
-
tile.type = "button";
|
|
441
|
-
tile.className = "media-browser__item";
|
|
442
|
-
tile.title = filename;
|
|
443
|
-
tile.addEventListener("click", () => {
|
|
444
|
-
insertMedia(url, filename, isImage);
|
|
445
|
-
close();
|
|
446
|
-
});
|
|
447
|
-
|
|
448
|
-
if (isImage) {
|
|
449
|
-
const img = document.createElement("img");
|
|
450
|
-
img.src = `/image/s_240x240/${encodeURIComponent(url)}`;
|
|
451
|
-
img.alt = filename;
|
|
452
|
-
img.loading = "lazy";
|
|
453
|
-
img.className = "media-browser__thumbnail";
|
|
454
|
-
tile.append(img);
|
|
455
|
-
} else {
|
|
456
|
-
const icon = document.createElement("span");
|
|
457
|
-
icon.className = "media-browser__icon";
|
|
458
|
-
icon.textContent = getMediaIcon(mediaType);
|
|
459
|
-
tile.append(icon);
|
|
460
|
-
|
|
461
|
-
const name = document.createElement("span");
|
|
462
|
-
name.className = "media-browser__filename";
|
|
463
|
-
name.textContent = filename;
|
|
464
|
-
tile.append(name);
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
grid.append(tile);
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
function insertMedia(url, filename, isImage) {
|
|
472
|
-
const cm = editor.codemirror;
|
|
473
|
-
const cursor = cm.getCursor();
|
|
474
|
-
const text = isImage ? `` : `[${filename}](${url})`;
|
|
475
|
-
cm.replaceRange(text, cursor);
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
async function fetchMedia() {
|
|
479
|
-
loading.hidden = false;
|
|
480
|
-
loadMoreBtn.hidden = true;
|
|
481
|
-
|
|
482
|
-
try {
|
|
483
|
-
const url = new URL(endpoint, globalThis.location.origin);
|
|
484
|
-
url.searchParams.set("q", "source");
|
|
485
|
-
url.searchParams.set("limit", "20");
|
|
486
|
-
if (afterCursor) url.searchParams.set("after", afterCursor);
|
|
487
|
-
|
|
488
|
-
const response = await fetch(url.href, { credentials: "same-origin" });
|
|
489
|
-
if (!response.ok) throw new Error(response.statusText);
|
|
490
|
-
|
|
491
|
-
const data = await response.json();
|
|
492
|
-
const items = data.items || [];
|
|
493
|
-
allItems = allItems.concat(items);
|
|
494
|
-
|
|
495
|
-
afterCursor =
|
|
496
|
-
data.paging && data.paging.after ? data.paging.after : null;
|
|
497
|
-
loadMoreBtn.hidden = !afterCursor;
|
|
498
|
-
|
|
499
|
-
renderGrid();
|
|
500
|
-
} catch (error) {
|
|
501
|
-
const errorMsg = document.createElement("p");
|
|
502
|
-
errorMsg.className = "media-browser__error";
|
|
503
|
-
errorMsg.textContent = `Error loading media: ${error.message}`;
|
|
504
|
-
grid.replaceChildren(errorMsg);
|
|
505
|
-
} finally {
|
|
506
|
-
loading.hidden = true;
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
// Initial fetch
|
|
511
|
-
fetchMedia();
|
|
512
309
|
}
|
|
513
310
|
|
|
514
311
|
/**
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared media browser modal for browsing and selecting existing media files.
|
|
3
|
+
* Used by both the textarea (EasyMDE) and file-input components.
|
|
4
|
+
*
|
|
5
|
+
* @param {object} options
|
|
6
|
+
* @param {string} options.endpoint - Media endpoint URL (e.g., "/media")
|
|
7
|
+
* @param {Function} options.onSelect - Callback when a media item is selected: (url, filename, isImage) => void
|
|
8
|
+
* @param {Function} [options.onClose] - Optional callback when modal is closed without selection
|
|
9
|
+
* @param {string} [options.filterType] - Optional initial filter type ("all", "photo", "audio", "video")
|
|
10
|
+
*/
|
|
11
|
+
export function openMediaBrowser({ endpoint, onSelect, onClose, filterType }) {
|
|
12
|
+
let allItems = [];
|
|
13
|
+
let afterCursor = null;
|
|
14
|
+
let activeFilter = filterType || "all";
|
|
15
|
+
|
|
16
|
+
// Create modal overlay
|
|
17
|
+
const overlay = document.createElement("div");
|
|
18
|
+
overlay.className = "media-browser";
|
|
19
|
+
overlay.setAttribute("role", "dialog");
|
|
20
|
+
overlay.setAttribute("aria-modal", "true");
|
|
21
|
+
overlay.setAttribute("aria-label", "Browse media");
|
|
22
|
+
|
|
23
|
+
const modal = document.createElement("div");
|
|
24
|
+
modal.className = "media-browser__modal";
|
|
25
|
+
|
|
26
|
+
// Header
|
|
27
|
+
const header = document.createElement("div");
|
|
28
|
+
header.className = "media-browser__header";
|
|
29
|
+
|
|
30
|
+
const title = document.createElement("h2");
|
|
31
|
+
title.className = "media-browser__title";
|
|
32
|
+
title.textContent = "Browse media";
|
|
33
|
+
header.append(title);
|
|
34
|
+
|
|
35
|
+
const closeBtn = document.createElement("button");
|
|
36
|
+
closeBtn.type = "button";
|
|
37
|
+
closeBtn.className = "media-browser__close";
|
|
38
|
+
closeBtn.setAttribute("aria-label", "Close");
|
|
39
|
+
closeBtn.textContent = "\u00D7";
|
|
40
|
+
closeBtn.addEventListener("click", close);
|
|
41
|
+
header.append(closeBtn);
|
|
42
|
+
|
|
43
|
+
// Filters
|
|
44
|
+
const filters = document.createElement("div");
|
|
45
|
+
filters.className = "media-browser__filters";
|
|
46
|
+
|
|
47
|
+
for (const filter of ["all", "photo", "audio", "video"]) {
|
|
48
|
+
const btn = document.createElement("button");
|
|
49
|
+
btn.type = "button";
|
|
50
|
+
btn.className = "media-browser__filter";
|
|
51
|
+
if (filter === activeFilter) btn.classList.add("is-active");
|
|
52
|
+
btn.textContent = filter.charAt(0).toUpperCase() + filter.slice(1);
|
|
53
|
+
btn.dataset.filter = filter;
|
|
54
|
+
btn.addEventListener("click", () => {
|
|
55
|
+
activeFilter = filter;
|
|
56
|
+
for (const f of filters.querySelectorAll(".media-browser__filter")) {
|
|
57
|
+
f.classList.toggle("is-active", f.dataset.filter === filter);
|
|
58
|
+
}
|
|
59
|
+
renderGrid();
|
|
60
|
+
});
|
|
61
|
+
filters.append(btn);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Grid
|
|
65
|
+
const grid = document.createElement("div");
|
|
66
|
+
grid.className = "media-browser__grid";
|
|
67
|
+
|
|
68
|
+
// Loading
|
|
69
|
+
const loading = document.createElement("div");
|
|
70
|
+
loading.className = "media-browser__loading";
|
|
71
|
+
loading.textContent = "Loading\u2026";
|
|
72
|
+
|
|
73
|
+
// Empty
|
|
74
|
+
const empty = document.createElement("p");
|
|
75
|
+
empty.className = "media-browser__empty";
|
|
76
|
+
empty.textContent = "No media files found.";
|
|
77
|
+
empty.hidden = true;
|
|
78
|
+
|
|
79
|
+
// Load more
|
|
80
|
+
const loadMoreBtn = document.createElement("button");
|
|
81
|
+
loadMoreBtn.type = "button";
|
|
82
|
+
loadMoreBtn.className = "media-browser__load-more";
|
|
83
|
+
loadMoreBtn.textContent = "Load more";
|
|
84
|
+
loadMoreBtn.hidden = true;
|
|
85
|
+
loadMoreBtn.addEventListener("click", () => fetchMedia());
|
|
86
|
+
|
|
87
|
+
modal.append(header, filters, grid, loading, empty, loadMoreBtn);
|
|
88
|
+
overlay.append(modal);
|
|
89
|
+
document.body.append(overlay);
|
|
90
|
+
|
|
91
|
+
// Lock body scroll
|
|
92
|
+
document.body.style.overflow = "hidden";
|
|
93
|
+
|
|
94
|
+
// Close handlers
|
|
95
|
+
overlay.addEventListener("click", (event) => {
|
|
96
|
+
if (event.target === overlay) close();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const onKeyDown = (event) => {
|
|
100
|
+
if (event.key === "Escape") close();
|
|
101
|
+
};
|
|
102
|
+
document.addEventListener("keydown", onKeyDown);
|
|
103
|
+
|
|
104
|
+
function close() {
|
|
105
|
+
overlay.remove();
|
|
106
|
+
document.body.style.overflow = "";
|
|
107
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
108
|
+
if (onClose) onClose();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function getFilteredItems() {
|
|
112
|
+
if (activeFilter === "all") return allItems;
|
|
113
|
+
return allItems.filter((item) => {
|
|
114
|
+
const type = item["media-type"] || "";
|
|
115
|
+
return type === activeFilter;
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function isImageType(mediaType) {
|
|
120
|
+
return mediaType === "photo";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function getMediaIcon(mediaType) {
|
|
124
|
+
if (!mediaType) return "\uD83D\uDCC4";
|
|
125
|
+
if (mediaType === "audio") return "\uD83C\uDFB5";
|
|
126
|
+
if (mediaType === "video") return "\uD83C\uDFAC";
|
|
127
|
+
return "\uD83D\uDCC4";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function getFilename(url) {
|
|
131
|
+
try {
|
|
132
|
+
return decodeURIComponent(url.split("/").pop());
|
|
133
|
+
} catch {
|
|
134
|
+
return url.split("/").pop();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function renderGrid() {
|
|
139
|
+
grid.replaceChildren();
|
|
140
|
+
const filtered = getFilteredItems();
|
|
141
|
+
empty.hidden = filtered.length > 0;
|
|
142
|
+
|
|
143
|
+
for (const item of filtered) {
|
|
144
|
+
const url = item.url;
|
|
145
|
+
const mediaType = item["media-type"] || "";
|
|
146
|
+
const isImage = isImageType(mediaType);
|
|
147
|
+
const filename = getFilename(url);
|
|
148
|
+
|
|
149
|
+
const tile = document.createElement("button");
|
|
150
|
+
tile.type = "button";
|
|
151
|
+
tile.className = "media-browser__item";
|
|
152
|
+
tile.title = filename;
|
|
153
|
+
tile.addEventListener("click", () => {
|
|
154
|
+
onSelect(url, filename, isImage);
|
|
155
|
+
close();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
if (isImage) {
|
|
159
|
+
const img = document.createElement("img");
|
|
160
|
+
img.src = `/image/s_240x240/${encodeURIComponent(url)}`;
|
|
161
|
+
img.alt = filename;
|
|
162
|
+
img.loading = "lazy";
|
|
163
|
+
img.className = "media-browser__thumbnail";
|
|
164
|
+
tile.append(img);
|
|
165
|
+
} else {
|
|
166
|
+
const icon = document.createElement("span");
|
|
167
|
+
icon.className = "media-browser__icon";
|
|
168
|
+
icon.textContent = getMediaIcon(mediaType);
|
|
169
|
+
tile.append(icon);
|
|
170
|
+
|
|
171
|
+
const name = document.createElement("span");
|
|
172
|
+
name.className = "media-browser__filename";
|
|
173
|
+
name.textContent = filename;
|
|
174
|
+
tile.append(name);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
grid.append(tile);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function fetchMedia() {
|
|
182
|
+
loading.hidden = false;
|
|
183
|
+
loadMoreBtn.hidden = true;
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
const url = new URL(endpoint, globalThis.location.origin);
|
|
187
|
+
url.searchParams.set("q", "source");
|
|
188
|
+
url.searchParams.set("limit", "20");
|
|
189
|
+
if (afterCursor) url.searchParams.set("after", afterCursor);
|
|
190
|
+
|
|
191
|
+
const response = await fetch(url.href, { credentials: "same-origin" });
|
|
192
|
+
if (!response.ok) throw new Error(response.statusText);
|
|
193
|
+
|
|
194
|
+
const data = await response.json();
|
|
195
|
+
const items = data.items || [];
|
|
196
|
+
allItems = allItems.concat(items);
|
|
197
|
+
|
|
198
|
+
afterCursor =
|
|
199
|
+
data.paging && data.paging.after ? data.paging.after : null;
|
|
200
|
+
loadMoreBtn.hidden = !afterCursor;
|
|
201
|
+
|
|
202
|
+
renderGrid();
|
|
203
|
+
} catch (error) {
|
|
204
|
+
const errorMsg = document.createElement("p");
|
|
205
|
+
errorMsg.className = "media-browser__error";
|
|
206
|
+
errorMsg.textContent = `Error loading media: ${error.message}`;
|
|
207
|
+
grid.replaceChildren(errorMsg);
|
|
208
|
+
} finally {
|
|
209
|
+
loading.hidden = true;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Initial fetch
|
|
214
|
+
fetchMedia();
|
|
215
|
+
}
|
package/lib/serviceworker.js
CHANGED
|
@@ -91,9 +91,10 @@ async function clearPagesCache() {
|
|
|
91
91
|
* Notify all clients that the service worker has been updated
|
|
92
92
|
*/
|
|
93
93
|
async function notifyClients() {
|
|
94
|
+
const version = assetCacheName.replace("assets-", "");
|
|
94
95
|
const allClients = await clients.matchAll({ includeUncontrolled: true });
|
|
95
96
|
for (const client of allClients) {
|
|
96
|
-
client.postMessage({ command: "SW_UPDATED", version
|
|
97
|
+
client.postMessage({ command: "SW_UPDATED", version });
|
|
97
98
|
}
|
|
98
99
|
}
|
|
99
100
|
|
|
@@ -130,7 +131,10 @@ self.addEventListener("activate", async (event) => {
|
|
|
130
131
|
event.waitUntil(
|
|
131
132
|
(async () => {
|
|
132
133
|
await clearOldCaches();
|
|
133
|
-
|
|
134
|
+
// Don't clear pages cache on activate — stale cached pages provide a
|
|
135
|
+
// valuable fallback when the network is slow (e.g. right after a deploy).
|
|
136
|
+
// The network-first fetch strategy naturally updates cached pages on
|
|
137
|
+
// every successful navigation, so stale entries are short-lived.
|
|
134
138
|
await clients.claim();
|
|
135
139
|
await notifyClients();
|
|
136
140
|
})(),
|
|
@@ -161,7 +165,7 @@ self.addEventListener("fetch", (event) => {
|
|
|
161
165
|
// For HTML requests, try network with timeout, fall back to cache
|
|
162
166
|
if (
|
|
163
167
|
request.mode === "navigate" ||
|
|
164
|
-
request.headers.get("Accept").includes("text/html")
|
|
168
|
+
(request.headers.get("Accept") || "").includes("text/html")
|
|
165
169
|
) {
|
|
166
170
|
event.respondWith(
|
|
167
171
|
(async () => {
|