@rmdes/indiekit-frontend 1.0.0-beta.32 → 1.0.0-beta.34
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/layouts/default.njk +6 -0
- package/lib/media-browser.js +215 -0
- package/lib/serviceworker.js +128 -45
- 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
|
/**
|
package/layouts/default.njk
CHANGED
|
@@ -122,6 +122,12 @@
|
|
|
122
122
|
{% endblock %}
|
|
123
123
|
<script type="module">
|
|
124
124
|
if (navigator.serviceWorker) {
|
|
125
|
+
// Reload page when a new service worker activates (new deploy)
|
|
126
|
+
navigator.serviceWorker.addEventListener("message", (event) => {
|
|
127
|
+
if (event.data && event.data.command === "SW_UPDATED") {
|
|
128
|
+
window.location.reload();
|
|
129
|
+
}
|
|
130
|
+
});
|
|
125
131
|
window.addEventListener("load", () => {
|
|
126
132
|
navigator.serviceWorker.register("/serviceworker.js", {
|
|
127
133
|
scope: '/'
|
|
@@ -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
|
@@ -8,6 +8,15 @@ const cacheList = new Set([assetCacheName, pagesCacheName, imageCacheName]);
|
|
|
8
8
|
const placeholderImage = `<svg xmlns="http://www.w3.org/2000/svg"><defs><path id="icon" fill="#AAA" d="M24 32a5 5 0 1 1 0 10 5 5 0 0 1 0-10Zm-6.9-11.9 4.1 4.1a17 17 0 0 0-9.7 5.3L8 26a22 22 0 0 1 9-6Zm22.5 5.4L36 29l-.8-.8L26 19a22 22 0 0 1 13.5 6.4ZM8.2 11.2l3.7 3.7a24.7 24.7 0 0 0-8.4 6.6l-3.6-3.6c2.4-2.7 5.2-5 8.3-6.7ZM24 7a32 32 0 0 1 23.4 10.2l-3.5 3.6a27 27 0 0 0-24.5-8.4l-4.2-4.2A32 32 0 0 1 24 7ZM2 5l3-3 41 41-3 3L2 5Z" opacity=".7"/>
|
|
9
9
|
</defs><rect fill="#000" width="100%" height="100%" opacity="0.075"/><use href="#icon" x="50%" y="50%" transform="translate(-24 -24)"/></svg>`;
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Check if a URL is a hashed asset (content-addressable, immutable)
|
|
13
|
+
* @param {string} url - Request URL
|
|
14
|
+
* @returns {boolean}
|
|
15
|
+
*/
|
|
16
|
+
function isHashedAsset(url) {
|
|
17
|
+
return /\/assets\/app-[a-f0-9]+\.(js|css)$/.test(url);
|
|
18
|
+
}
|
|
19
|
+
|
|
11
20
|
/**
|
|
12
21
|
* Update asset cache
|
|
13
22
|
* @returns {Promise<Cache>} - Updated asset cache
|
|
@@ -16,7 +25,7 @@ async function updateAssetCache() {
|
|
|
16
25
|
try {
|
|
17
26
|
const assetCache = await caches.open(assetCacheName);
|
|
18
27
|
|
|
19
|
-
// These items won
|
|
28
|
+
// These items won't block the installation of the service worker
|
|
20
29
|
assetCache.addAll(["/app.webmanifest"]);
|
|
21
30
|
|
|
22
31
|
// These items must be cached for service worker to complete installation
|
|
@@ -67,6 +76,27 @@ async function clearOldCaches() {
|
|
|
67
76
|
}
|
|
68
77
|
}
|
|
69
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Clear the pages cache so stale HTML (with old asset references) is not served
|
|
81
|
+
*/
|
|
82
|
+
async function clearPagesCache() {
|
|
83
|
+
try {
|
|
84
|
+
await caches.delete(pagesCacheName);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
console.error("Error clearing pages cache", error);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Notify all clients that the service worker has been updated
|
|
92
|
+
*/
|
|
93
|
+
async function notifyClients() {
|
|
94
|
+
const allClients = await clients.matchAll({ includeUncontrolled: true });
|
|
95
|
+
for (const client of allClients) {
|
|
96
|
+
client.postMessage({ command: "SW_UPDATED", version: "APP_VERSION" });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
70
100
|
/**
|
|
71
101
|
* Trim cache
|
|
72
102
|
* @param {string} cacheName - Name of cache
|
|
@@ -100,7 +130,9 @@ self.addEventListener("activate", async (event) => {
|
|
|
100
130
|
event.waitUntil(
|
|
101
131
|
(async () => {
|
|
102
132
|
await clearOldCaches();
|
|
133
|
+
await clearPagesCache();
|
|
103
134
|
await clients.claim();
|
|
135
|
+
await notifyClients();
|
|
104
136
|
})(),
|
|
105
137
|
);
|
|
106
138
|
});
|
|
@@ -126,47 +158,40 @@ self.addEventListener("fetch", (event) => {
|
|
|
126
158
|
return;
|
|
127
159
|
}
|
|
128
160
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
// For HTML requests, try network, fall back to cache, else show offline page
|
|
161
|
+
// For HTML requests, try network with timeout, fall back to cache
|
|
132
162
|
if (
|
|
133
163
|
request.mode === "navigate" ||
|
|
134
164
|
request.headers.get("Accept").includes("text/html")
|
|
135
165
|
) {
|
|
136
166
|
event.respondWith(
|
|
137
167
|
(async () => {
|
|
138
|
-
// CHECK CACHE
|
|
139
|
-
const timer = setTimeout(async () => {
|
|
140
|
-
const responseFromCache = await retrieveFromCache;
|
|
141
|
-
if (responseFromCache) {
|
|
142
|
-
return responseFromCache;
|
|
143
|
-
}
|
|
144
|
-
}, timeout);
|
|
145
|
-
|
|
146
168
|
try {
|
|
147
|
-
|
|
148
|
-
const responseFromPreloadOrFetch =
|
|
149
|
-
|
|
169
|
+
// Race network against a timeout for faster fallback on slow connections
|
|
170
|
+
const responseFromPreloadOrFetch = await Promise.race([
|
|
171
|
+
(async () => {
|
|
172
|
+
const preloadResponse = await Promise.resolve(
|
|
173
|
+
event.preloadResponse,
|
|
174
|
+
);
|
|
175
|
+
return preloadResponse || (await fetch(request));
|
|
176
|
+
})(),
|
|
177
|
+
new Promise((_, reject) =>
|
|
178
|
+
setTimeout(() => reject(new Error("Network timeout")), timeout),
|
|
179
|
+
),
|
|
180
|
+
]);
|
|
150
181
|
|
|
151
|
-
// NETWORK
|
|
152
|
-
// Save a copy of page to pages cache
|
|
153
|
-
clearTimeout(timer);
|
|
182
|
+
// NETWORK succeeded — cache and serve
|
|
154
183
|
try {
|
|
155
184
|
const copy = responseFromPreloadOrFetch.clone();
|
|
156
185
|
const pagesCache = await caches.open(pagesCacheName);
|
|
157
186
|
await pagesCache.put(request, copy);
|
|
158
187
|
} catch (cacheError) {
|
|
159
|
-
// Cache put failed (e.g., network error response), but continue serving the response
|
|
160
188
|
console.error("Failed to cache page:", cacheError);
|
|
161
189
|
}
|
|
162
190
|
|
|
163
191
|
return responseFromPreloadOrFetch;
|
|
164
|
-
} catch
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
// CACHE or OFFLINE PAGE
|
|
168
|
-
clearTimeout(timer);
|
|
169
|
-
const responseFromCache = await retrieveFromCache;
|
|
192
|
+
} catch {
|
|
193
|
+
// NETWORK failed or timed out — fall back to cache
|
|
194
|
+
const responseFromCache = await caches.match(request);
|
|
170
195
|
const offlineResponse = await caches.match("/offline");
|
|
171
196
|
return (
|
|
172
197
|
responseFromCache ||
|
|
@@ -184,33 +209,93 @@ self.addEventListener("fetch", (event) => {
|
|
|
184
209
|
return;
|
|
185
210
|
}
|
|
186
211
|
|
|
187
|
-
// For
|
|
212
|
+
// For hashed assets (e.g. /assets/app-abc123.js): cache-first
|
|
213
|
+
// These URLs are content-addressable — the content never changes for a given hash
|
|
214
|
+
if (isHashedAsset(request.url)) {
|
|
215
|
+
event.respondWith(
|
|
216
|
+
(async () => {
|
|
217
|
+
const responseFromCache = await caches.match(request);
|
|
218
|
+
if (responseFromCache) {
|
|
219
|
+
return responseFromCache;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
const responseFromFetch = await fetch(request);
|
|
224
|
+
const copy = responseFromFetch.clone();
|
|
225
|
+
const assetCache = await caches.open(assetCacheName);
|
|
226
|
+
await assetCache.put(request, copy);
|
|
227
|
+
return responseFromFetch;
|
|
228
|
+
} catch (error) {
|
|
229
|
+
console.error("Fetch failed for hashed asset:", error, request.url);
|
|
230
|
+
return new Response("Network error", {
|
|
231
|
+
status: 503,
|
|
232
|
+
statusText: "Service Unavailable",
|
|
233
|
+
headers: { "Content-Type": "text/plain" },
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
})(),
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// For other non-HTML requests: stale-while-revalidate
|
|
243
|
+
// Serve from cache immediately, update cache in background
|
|
188
244
|
event.respondWith(
|
|
189
245
|
(async () => {
|
|
190
246
|
try {
|
|
191
|
-
const responseFromCache = await
|
|
247
|
+
const responseFromCache = await caches.match(request);
|
|
248
|
+
|
|
249
|
+
// Start network fetch regardless (to update cache)
|
|
250
|
+
const fetchPromise = fetch(request)
|
|
251
|
+
.then(async (responseFromFetch) => {
|
|
252
|
+
// Update cache with fresh response
|
|
253
|
+
if (/\.(jpe?g|png|gif|svg|webp)/.test(request.url)) {
|
|
254
|
+
try {
|
|
255
|
+
const copy = responseFromFetch.clone();
|
|
256
|
+
const imagesCache = await caches.open(imageCacheName);
|
|
257
|
+
await imagesCache.put(request, copy);
|
|
258
|
+
} catch (cacheError) {
|
|
259
|
+
console.error("Failed to cache image:", cacheError);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return responseFromFetch;
|
|
263
|
+
})
|
|
264
|
+
.catch((error) => {
|
|
265
|
+
console.error(
|
|
266
|
+
"Background fetch failed:",
|
|
267
|
+
error,
|
|
268
|
+
request.url,
|
|
269
|
+
);
|
|
270
|
+
return null;
|
|
271
|
+
});
|
|
192
272
|
|
|
193
273
|
if (responseFromCache) {
|
|
194
|
-
// CACHE
|
|
274
|
+
// CACHE HIT — serve cached, update in background
|
|
195
275
|
return responseFromCache;
|
|
196
|
-
}
|
|
197
|
-
const responseFromFetch = await fetch(request);
|
|
198
|
-
|
|
199
|
-
// NETWORK
|
|
200
|
-
// If request is for an image, save a copy to images cache
|
|
201
|
-
if (/\.(jpe?g|png|gif|svg|webp)/.test(request.url)) {
|
|
202
|
-
try {
|
|
203
|
-
const copy = responseFromFetch.clone();
|
|
204
|
-
const imagesCache = await caches.open(imageCacheName);
|
|
205
|
-
await imagesCache.put(request, copy);
|
|
206
|
-
} catch (cacheError) {
|
|
207
|
-
// Cache put failed (e.g., network error response), but continue serving the response
|
|
208
|
-
console.error("Failed to cache image:", cacheError);
|
|
209
|
-
}
|
|
210
|
-
}
|
|
276
|
+
}
|
|
211
277
|
|
|
278
|
+
// CACHE MISS — wait for network
|
|
279
|
+
const responseFromFetch = await fetchPromise;
|
|
280
|
+
if (responseFromFetch) {
|
|
212
281
|
return responseFromFetch;
|
|
213
282
|
}
|
|
283
|
+
|
|
284
|
+
// OFFLINE IMAGE
|
|
285
|
+
if (/\.(jpe?g|png|gif|svg|webp)/.test(request.url)) {
|
|
286
|
+
return new Response(placeholderImage, {
|
|
287
|
+
headers: {
|
|
288
|
+
"Content-Type": "image/svg+xml",
|
|
289
|
+
"Cache-Control": "no-store",
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return new Response("Network error", {
|
|
295
|
+
status: 503,
|
|
296
|
+
statusText: "Service Unavailable",
|
|
297
|
+
headers: { "Content-Type": "text/plain" },
|
|
298
|
+
});
|
|
214
299
|
} catch (error) {
|
|
215
300
|
console.error(
|
|
216
301
|
"Fetch failed for non-HTML resource:",
|
|
@@ -218,7 +303,6 @@ self.addEventListener("fetch", (event) => {
|
|
|
218
303
|
request.url,
|
|
219
304
|
);
|
|
220
305
|
|
|
221
|
-
// OFFLINE IMAGE
|
|
222
306
|
if (/\.(jpe?g|png|gif|svg|webp)/.test(request.url)) {
|
|
223
307
|
return new Response(placeholderImage, {
|
|
224
308
|
headers: {
|
|
@@ -228,7 +312,6 @@ self.addEventListener("fetch", (event) => {
|
|
|
228
312
|
});
|
|
229
313
|
}
|
|
230
314
|
|
|
231
|
-
// For other resources, return a network error response
|
|
232
315
|
return new Response("Network error", {
|
|
233
316
|
status: 503,
|
|
234
317
|
statusText: "Service Unavailable",
|