@rmdes/indiekit-endpoint-microsub 1.0.56 → 1.0.58

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.
Files changed (51) hide show
  1. package/assets/reader.js +408 -0
  2. package/index.js +37 -36
  3. package/lib/cache/redis.js +12 -3
  4. package/lib/controllers/reader/actor.js +142 -0
  5. package/lib/controllers/reader/channel.js +301 -0
  6. package/lib/controllers/reader/compose.js +242 -0
  7. package/lib/controllers/reader/deck.js +129 -0
  8. package/lib/controllers/reader/feed-repair.js +117 -0
  9. package/lib/controllers/reader/feed.js +246 -0
  10. package/lib/controllers/reader/index.js +126 -0
  11. package/lib/controllers/reader/search.js +157 -0
  12. package/lib/controllers/reader/timeline.js +251 -0
  13. package/lib/controllers/timeline.js +4 -2
  14. package/lib/feeds/atom.js +1 -1
  15. package/lib/feeds/fetcher.js +1 -30
  16. package/lib/feeds/hfeed.js +1 -1
  17. package/lib/feeds/jsonfeed.js +1 -1
  18. package/lib/feeds/normalizer-hfeed.js +209 -0
  19. package/lib/feeds/normalizer-jsonfeed.js +171 -0
  20. package/lib/feeds/normalizer-rss.js +178 -0
  21. package/lib/feeds/normalizer.js +20 -560
  22. package/lib/feeds/rss.js +1 -1
  23. package/lib/polling/processor.js +3 -17
  24. package/lib/storage/items-read-state.js +287 -0
  25. package/lib/storage/items-retention.js +174 -0
  26. package/lib/storage/items-search.js +34 -0
  27. package/lib/storage/items.js +99 -590
  28. package/lib/storage/read-state.js +1 -1
  29. package/lib/utils/async-handler.js +7 -0
  30. package/lib/utils/html.js +25 -0
  31. package/lib/utils/source-type.js +28 -0
  32. package/lib/webmention/processor.js +1 -1
  33. package/locales/de.json +3 -0
  34. package/locales/en.json +2 -0
  35. package/locales/es-419.json +3 -0
  36. package/locales/es.json +3 -0
  37. package/locales/fr.json +3 -0
  38. package/locales/hi.json +3 -0
  39. package/locales/id.json +3 -0
  40. package/locales/it.json +3 -0
  41. package/locales/nl.json +3 -0
  42. package/locales/pl.json +3 -0
  43. package/locales/pt-BR.json +3 -0
  44. package/locales/pt.json +3 -0
  45. package/locales/sr.json +3 -0
  46. package/locales/sv.json +3 -0
  47. package/locales/zh-Hans-CN.json +3 -0
  48. package/package.json +1 -1
  49. package/views/channel.njk +1 -348
  50. package/views/timeline.njk +3 -274
  51. package/lib/controllers/reader.js +0 -1562
@@ -3,7 +3,7 @@
3
3
  * @module storage/read-state
4
4
  */
5
5
 
6
- import { markItemsRead, markItemsUnread, getUnreadCount } from "./items.js";
6
+ import { markItemsRead, markItemsUnread, getUnreadCount } from "./items-read-state.js";
7
7
 
8
8
  /**
9
9
  * Mark entries as read for a user
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Wrap async route handlers to catch rejections and pass to Express error middleware
3
+ * @param {Function} fn - Async route handler
4
+ * @returns {Function} Wrapped handler
5
+ */
6
+ export const asyncHandler = (fn) => (req, res, next) =>
7
+ Promise.resolve(fn(req, res, next)).catch(next);
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Shared HTML utilities
3
+ * @module utils/html
4
+ */
5
+
6
+ /**
7
+ * Extract image URLs from HTML content (fallback for items without explicit photos)
8
+ * @param {string} html - HTML content
9
+ * @returns {string[]} Array of image URLs
10
+ */
11
+ export function extractImagesFromHtml(html) {
12
+ if (!html) {
13
+ return [];
14
+ }
15
+ const urls = [];
16
+ const imgRegex = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
17
+ let match;
18
+ while ((match = imgRegex.exec(html)) !== null) {
19
+ const src = match[1];
20
+ if (src && !urls.includes(src)) {
21
+ urls.push(src);
22
+ }
23
+ }
24
+ return urls;
25
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * URL classification utilities
3
+ * @module utils/source-type
4
+ */
5
+
6
+ /**
7
+ * Classify a URL by its platform/protocol
8
+ * @param {string} url - URL to classify
9
+ * @returns {{ type: string, protocol: string }}
10
+ */
11
+ export function classifyUrl(url) {
12
+ if (!url || typeof url !== "string") return { type: "web", protocol: "web" };
13
+ const lower = url.toLowerCase();
14
+ if (lower.includes("bsky.app") || lower.includes("bluesky")) {
15
+ return { type: "bluesky", protocol: "atmosphere" };
16
+ }
17
+ if (
18
+ lower.includes("mastodon.") ||
19
+ lower.includes("mstdn.") ||
20
+ lower.includes("fosstodon.") ||
21
+ lower.includes("pleroma.") ||
22
+ lower.includes("misskey.") ||
23
+ lower.includes("pixelfed.")
24
+ ) {
25
+ return { type: "mastodon", protocol: "fediverse" };
26
+ }
27
+ return { type: "web", protocol: "web" };
28
+ }
@@ -78,7 +78,7 @@ export async function processWebmention(application, source, target, userId) {
78
78
  }
79
79
 
80
80
  // Publish real-time event
81
- const redis = getRedisClient(application);
81
+ const redis = await getRedisClient(application);
82
82
  if (redis && userId) {
83
83
  await publishEvent(redis, `microsub:user:${userId}`, {
84
84
  type: "new-notification",
package/locales/de.json CHANGED
@@ -4,6 +4,9 @@
4
4
  "title": "Leser",
5
5
  "empty": "Keine Elemente zum Anzeigen",
6
6
  "markAllRead": "Alle als gelesen markieren",
7
+ "markViewRead": "Mark current view as read",
8
+ "filterChannels": "Filter channels",
9
+ "apply": "Apply",
7
10
  "showRead": "Gelesene anzeigen ({{count}})",
8
11
  "hideRead": "Gelesene Elemente ausblenden",
9
12
  "allRead": "Alles aufgeholt!",
package/locales/en.json CHANGED
@@ -5,6 +5,8 @@
5
5
  "empty": "No items to display",
6
6
  "markAllRead": "Mark all as read",
7
7
  "markViewRead": "Mark current view as read",
8
+ "filterChannels": "Filter channels",
9
+ "apply": "Apply",
8
10
  "showRead": "Show read ({{count}})",
9
11
  "hideRead": "Hide read items",
10
12
  "allRead": "All caught up!",
@@ -4,6 +4,9 @@
4
4
  "title": "Lector",
5
5
  "empty": "No hay elementos para mostrar",
6
6
  "markAllRead": "Marcar todo como leído",
7
+ "markViewRead": "Mark current view as read",
8
+ "filterChannels": "Filter channels",
9
+ "apply": "Apply",
7
10
  "showRead": "Mostrar leídos ({{count}})",
8
11
  "hideRead": "Ocultar elementos leídos",
9
12
  "allRead": "¡Todo al día!",
package/locales/es.json CHANGED
@@ -4,6 +4,9 @@
4
4
  "title": "Lector",
5
5
  "empty": "No hay elementos para mostrar",
6
6
  "markAllRead": "Marcar todo como leído",
7
+ "markViewRead": "Mark current view as read",
8
+ "filterChannels": "Filter channels",
9
+ "apply": "Apply",
7
10
  "showRead": "Mostrar leídos ({{count}})",
8
11
  "hideRead": "Ocultar elementos leídos",
9
12
  "allRead": "¡Todo al día!",
package/locales/fr.json CHANGED
@@ -4,6 +4,9 @@
4
4
  "title": "Lecteur",
5
5
  "empty": "Aucun élément à afficher",
6
6
  "markAllRead": "Tout marquer comme lu",
7
+ "markViewRead": "Mark current view as read",
8
+ "filterChannels": "Filter channels",
9
+ "apply": "Apply",
7
10
  "showRead": "Afficher les lus ({{count}})",
8
11
  "hideRead": "Masquer les éléments lus",
9
12
  "allRead": "Tout est à jour !",
package/locales/hi.json CHANGED
@@ -4,6 +4,9 @@
4
4
  "title": "रीडर",
5
5
  "empty": "प्रदर्शित करने के लिए कोई आइटम नहीं",
6
6
  "markAllRead": "सभी को पढ़ा हुआ चिह्नित करें",
7
+ "markViewRead": "Mark current view as read",
8
+ "filterChannels": "Filter channels",
9
+ "apply": "Apply",
7
10
  "showRead": "पढ़े हुए दिखाएं ({{count}})",
8
11
  "hideRead": "पढ़े हुए आइटम छिपाएं",
9
12
  "allRead": "सब पकड़ लिया!",
package/locales/id.json CHANGED
@@ -4,6 +4,9 @@
4
4
  "title": "Pembaca",
5
5
  "empty": "Tidak ada item untuk ditampilkan",
6
6
  "markAllRead": "Tandai semua sudah dibaca",
7
+ "markViewRead": "Mark current view as read",
8
+ "filterChannels": "Filter channels",
9
+ "apply": "Apply",
7
10
  "showRead": "Tampilkan yang sudah dibaca ({{count}})",
8
11
  "hideRead": "Sembunyikan item yang sudah dibaca",
9
12
  "allRead": "Semua sudah terbaca!",
package/locales/it.json CHANGED
@@ -4,6 +4,9 @@
4
4
  "title": "Lettore",
5
5
  "empty": "Nessun elemento da visualizzare",
6
6
  "markAllRead": "Segna tutto come letto",
7
+ "markViewRead": "Mark current view as read",
8
+ "filterChannels": "Filter channels",
9
+ "apply": "Apply",
7
10
  "showRead": "Mostra letti ({{count}})",
8
11
  "hideRead": "Nascondi elementi letti",
9
12
  "allRead": "Tutto aggiornato!",
package/locales/nl.json CHANGED
@@ -4,6 +4,9 @@
4
4
  "title": "Lezer",
5
5
  "empty": "Geen items om weer te geven",
6
6
  "markAllRead": "Alles als gelezen markeren",
7
+ "markViewRead": "Mark current view as read",
8
+ "filterChannels": "Filter channels",
9
+ "apply": "Apply",
7
10
  "showRead": "Toon gelezen ({{count}})",
8
11
  "hideRead": "Verberg gelezen items",
9
12
  "allRead": "Alles bijgewerkt!",
package/locales/pl.json CHANGED
@@ -4,6 +4,9 @@
4
4
  "title": "Czytnik",
5
5
  "empty": "Brak elementów do wyświetlenia",
6
6
  "markAllRead": "Oznacz wszystkie jako przeczytane",
7
+ "markViewRead": "Mark current view as read",
8
+ "filterChannels": "Filter channels",
9
+ "apply": "Apply",
7
10
  "showRead": "Pokaż przeczytane ({{count}})",
8
11
  "hideRead": "Ukryj przeczytane elementy",
9
12
  "allRead": "Wszystko przeczytane!",
@@ -4,6 +4,9 @@
4
4
  "title": "Leitor",
5
5
  "empty": "Nenhum item para exibir",
6
6
  "markAllRead": "Marcar tudo como lido",
7
+ "markViewRead": "Mark current view as read",
8
+ "filterChannels": "Filter channels",
9
+ "apply": "Apply",
7
10
  "showRead": "Mostrar lidos ({{count}})",
8
11
  "hideRead": "Ocultar itens lidos",
9
12
  "allRead": "Tudo em dia!",
package/locales/pt.json CHANGED
@@ -4,6 +4,9 @@
4
4
  "title": "Leitor",
5
5
  "empty": "Nenhum item para exibir",
6
6
  "markAllRead": "Marcar tudo como lido",
7
+ "markViewRead": "Mark current view as read",
8
+ "filterChannels": "Filter channels",
9
+ "apply": "Apply",
7
10
  "showRead": "Mostrar lidos ({{count}})",
8
11
  "hideRead": "Ocultar itens lidos",
9
12
  "allRead": "Tudo em dia!",
package/locales/sr.json CHANGED
@@ -4,6 +4,9 @@
4
4
  "title": "Читач",
5
5
  "empty": "Нема ставки за приказ",
6
6
  "markAllRead": "Означи све као прочитано",
7
+ "markViewRead": "Mark current view as read",
8
+ "filterChannels": "Filter channels",
9
+ "apply": "Apply",
7
10
  "showRead": "Прикажи прочитано ({{count}})",
8
11
  "hideRead": "Сакриј прочитане ставке",
9
12
  "allRead": "Све ажурирано!",
package/locales/sv.json CHANGED
@@ -4,6 +4,9 @@
4
4
  "title": "Läsare",
5
5
  "empty": "Inga objekt att visa",
6
6
  "markAllRead": "Markera allt som läst",
7
+ "markViewRead": "Mark current view as read",
8
+ "filterChannels": "Filter channels",
9
+ "apply": "Apply",
7
10
  "showRead": "Visa lästa ({{count}})",
8
11
  "hideRead": "Dölj lästa objekt",
9
12
  "allRead": "Allt är uppdaterat!",
@@ -4,6 +4,9 @@
4
4
  "title": "阅读器",
5
5
  "empty": "没有可显示的项目",
6
6
  "markAllRead": "全部标记为已读",
7
+ "markViewRead": "Mark current view as read",
8
+ "filterChannels": "Filter channels",
9
+ "apply": "Apply",
7
10
  "showRead": "显示已读 ({{count}})",
8
11
  "hideRead": "隐藏已读项目",
9
12
  "allRead": "全部已读!",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-microsub",
3
- "version": "1.0.56",
3
+ "version": "1.0.58",
4
4
  "description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.",
5
5
  "keywords": [
6
6
  "indiekit",
package/views/channel.njk CHANGED
@@ -96,352 +96,5 @@
96
96
  {% endif %}
97
97
  </div>
98
98
 
99
- <script type="module">
100
- // CSRF token for AJAX requests
101
- const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
102
-
103
- // Keyboard navigation (j/k for items, o to open)
104
- const timeline = document.getElementById('timeline');
105
- if (timeline) {
106
- const items = Array.from(timeline.querySelectorAll('.ms-item-card'));
107
- let currentIndex = -1;
108
-
109
- function focusItem(index) {
110
- if (items[currentIndex]) {
111
- items[currentIndex].classList.remove('ms-item-card--focused');
112
- }
113
- currentIndex = Math.max(0, Math.min(index, items.length - 1));
114
- if (items[currentIndex]) {
115
- items[currentIndex].classList.add('ms-item-card--focused');
116
- items[currentIndex].scrollIntoView({ behavior: 'smooth', block: 'center' });
117
- }
118
- }
119
-
120
- document.addEventListener('keydown', (e) => {
121
- if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
122
-
123
- switch(e.key) {
124
- case 'j':
125
- e.preventDefault();
126
- focusItem(currentIndex + 1);
127
- break;
128
- case 'k':
129
- e.preventDefault();
130
- focusItem(currentIndex - 1);
131
- break;
132
- case 'o':
133
- case 'Enter':
134
- e.preventDefault();
135
- if (items[currentIndex]) {
136
- const link = items[currentIndex].querySelector('.ms-item-card__link');
137
- if (link) link.click();
138
- }
139
- break;
140
- }
141
- });
142
-
143
- // Handle individual mark-read buttons
144
- const channelUid = timeline.dataset.channel;
145
- // Microsub API is at the parent of /reader (e.g., /microsub not /microsub/reader)
146
- const microsubApiUrl = '{{ baseUrl }}'.replace(/\/reader$/, '');
147
-
148
- timeline.addEventListener('click', async (e) => {
149
- const button = e.target.closest('.ms-item-actions__mark-read');
150
- if (!button) return;
151
-
152
- e.preventDefault();
153
- e.stopPropagation();
154
-
155
- const itemId = button.dataset.itemId;
156
- if (!itemId) return;
157
-
158
- // Disable button while processing
159
- button.disabled = true;
160
-
161
- try {
162
- const formData = new URLSearchParams();
163
- formData.append('action', 'timeline');
164
- formData.append('method', 'mark_read');
165
- formData.append('channel', channelUid);
166
- formData.append('entry', itemId);
167
-
168
- const response = await fetch(microsubApiUrl, {
169
- method: 'POST',
170
- headers: {
171
- 'Content-Type': 'application/x-www-form-urlencoded',
172
- 'X-CSRF-Token': csrfToken,
173
- },
174
- body: formData.toString(),
175
- credentials: 'same-origin'
176
- });
177
-
178
- if (response.ok) {
179
- // Hide the item with animation
180
- const card = button.closest('.ms-item-card');
181
- if (card) {
182
- card.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
183
- card.style.opacity = '0';
184
- card.style.transform = 'translateX(-20px)';
185
- setTimeout(() => {
186
- card.remove();
187
- // Check if timeline is now empty
188
- if (timeline.querySelectorAll('.ms-item-card').length === 0) {
189
- location.reload();
190
- }
191
- }, 300);
192
- }
193
- } else {
194
- console.error('Failed to mark item as read');
195
- button.disabled = false;
196
- }
197
- } catch (error) {
198
- console.error('Error marking item as read:', error);
199
- button.disabled = false;
200
- }
201
- });
202
-
203
- // Handle caret toggle for mark-source-read popover
204
- timeline.addEventListener('click', (e) => {
205
- const caret = e.target.closest('.ms-item-actions__mark-read-caret');
206
- if (!caret) return;
207
-
208
- e.preventDefault();
209
- e.stopPropagation();
210
-
211
- // Close other open popovers
212
- for (const p of timeline.querySelectorAll('.ms-item-actions__mark-read-popover:not([hidden])')) {
213
- if (p !== caret.nextElementSibling) p.hidden = true;
214
- }
215
-
216
- const popover = caret.nextElementSibling;
217
- if (popover) popover.hidden = !popover.hidden;
218
- });
219
-
220
- // Handle mark-source-read button
221
- timeline.addEventListener('click', async (e) => {
222
- const button = e.target.closest('.ms-item-actions__mark-source-read');
223
- if (!button) return;
224
-
225
- e.preventDefault();
226
- e.stopPropagation();
227
-
228
- const feedId = button.dataset.feedId;
229
- if (!feedId) return;
230
-
231
- button.disabled = true;
232
-
233
- try {
234
- const formData = new URLSearchParams();
235
- formData.append('action', 'timeline');
236
- formData.append('method', 'mark_read_source');
237
- formData.append('channel', channelUid);
238
- formData.append('feed', feedId);
239
-
240
- const response = await fetch(microsubApiUrl, {
241
- method: 'POST',
242
- headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-Token': csrfToken },
243
- body: formData.toString(),
244
- credentials: 'same-origin'
245
- });
246
-
247
- if (response.ok) {
248
- // Animate out all cards from this feed
249
- const cards = timeline.querySelectorAll(`.ms-item-card[data-feed-id="${feedId}"]`);
250
- for (const card of cards) {
251
- card.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
252
- card.style.opacity = '0';
253
- card.style.transform = 'translateX(-20px)';
254
- }
255
- setTimeout(() => {
256
- for (const card of [...cards]) {
257
- card.remove();
258
- }
259
- if (timeline.querySelectorAll('.ms-item-card').length === 0) {
260
- location.reload();
261
- }
262
- }, 300);
263
- } else {
264
- button.disabled = false;
265
- }
266
- } catch (error) {
267
- console.error('Error marking source as read:', error);
268
- button.disabled = false;
269
- }
270
- });
271
-
272
- // Close popovers on outside click
273
- document.addEventListener('click', (e) => {
274
- if (!e.target.closest('.ms-item-actions__mark-read-group')) {
275
- for (const p of timeline.querySelectorAll('.ms-item-actions__mark-read-popover:not([hidden])')) {
276
- p.hidden = true;
277
- }
278
- }
279
- });
280
-
281
- // Handle save-for-later buttons
282
- timeline.addEventListener('click', async (e) => {
283
- const button = e.target.closest('.ms-item-actions__save-later');
284
- if (!button) return;
285
-
286
- e.preventDefault();
287
- e.stopPropagation();
288
-
289
- const url = button.dataset.url;
290
- const title = button.dataset.title;
291
- if (!url) return;
292
-
293
- button.disabled = true;
294
-
295
- try {
296
- const response = await fetch('/readlater/save', {
297
- method: 'POST',
298
- headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
299
- body: JSON.stringify({ url, title: title || url, source: 'microsub' }),
300
- credentials: 'same-origin'
301
- });
302
-
303
- if (response.ok) {
304
- button.classList.add('ms-item-actions__save-later--saved');
305
- button.title = 'Saved';
306
- } else {
307
- button.disabled = false;
308
- }
309
- } catch {
310
- button.disabled = false;
311
- }
312
- });
313
- }
314
-
315
- // === Infinite scroll ===
316
- const loader = document.getElementById('timeline-loader');
317
- if (loader && timeline) {
318
- const spinner = loader.querySelector('.ms-timeline__spinner');
319
- const loadMoreLink = loader.querySelector('.ms-timeline__load-more');
320
- const endMessage = loader.querySelector('.ms-timeline__end');
321
- let cursor = loader.dataset.cursor;
322
- let loading = false;
323
- let hasMore = true;
324
- const apiUrl = loader.dataset.apiUrl;
325
- const showReadParam = loader.dataset.showRead;
326
-
327
- async function loadMore() {
328
- if (loading || !hasMore) return;
329
- loading = true;
330
- if (spinner) spinner.style.display = '';
331
- if (loadMoreLink) loadMoreLink.style.display = 'none';
332
-
333
- try {
334
- const params = new URLSearchParams({ after: cursor });
335
- if (showReadParam === 'true') params.append('showRead', 'true');
336
-
337
- const response = await fetch(`${apiUrl}?${params}`, {
338
- credentials: 'same-origin'
339
- });
340
-
341
- if (!response.ok) throw new Error('Failed to load');
342
-
343
- const data = await response.json();
344
-
345
- if (data.html && data.count > 0) {
346
- timeline.insertAdjacentHTML('beforeend', data.html);
347
- }
348
-
349
- if (data.paging?.after) {
350
- cursor = data.paging.after;
351
- if (loadMoreLink) {
352
- loadMoreLink.href = `?after=${cursor}${showReadParam === 'true' ? '&showRead=true' : ''}`;
353
- loadMoreLink.style.display = '';
354
- }
355
- } else {
356
- hasMore = false;
357
- if (loadMoreLink) loadMoreLink.style.display = 'none';
358
- if (endMessage) endMessage.style.display = '';
359
- }
360
- } catch (error) {
361
- console.error('Infinite scroll error:', error);
362
- hasMore = false;
363
- if (loadMoreLink) loadMoreLink.style.display = '';
364
- } finally {
365
- loading = false;
366
- if (spinner) spinner.style.display = 'none';
367
- }
368
- }
369
-
370
- // IntersectionObserver auto-loads when sentinel is visible
371
- const observer = new IntersectionObserver((entries) => {
372
- if (entries[0].isIntersecting && hasMore && !loading) {
373
- loadMore();
374
- }
375
- }, { rootMargin: '200px' });
376
- observer.observe(loader);
377
-
378
- // Click handler for fallback link
379
- if (loadMoreLink) {
380
- loadMoreLink.addEventListener('click', (e) => {
381
- e.preventDefault();
382
- loadMore();
383
- });
384
- }
385
-
386
- // Expose loadMore for mark-view-as-read to trigger
387
- window.__microsubLoadMore = loadMore;
388
- }
389
-
390
- // === Mark current view as read button ===
391
- const markViewBtn = document.querySelector('.js-mark-view-read');
392
- if (markViewBtn && timeline) {
393
- markViewBtn.style.display = '';
394
- const markViewApiUrl = '{{ baseUrl }}'.replace(/\/reader$/, '');
395
-
396
- markViewBtn.addEventListener('click', async () => {
397
- const unreadCards = timeline.querySelectorAll('.ms-item-card:not(.ms-item-card--read)');
398
- const itemIds = [...unreadCards].map((card) => card.dataset.itemId).filter(Boolean);
399
-
400
- if (itemIds.length === 0) return;
401
-
402
- markViewBtn.disabled = true;
403
-
404
- const formData = new URLSearchParams();
405
- formData.append('action', 'timeline');
406
- formData.append('method', 'mark_read');
407
- formData.append('channel', markViewBtn.dataset.channel);
408
- for (const id of itemIds) {
409
- formData.append('entry', id);
410
- }
411
-
412
- try {
413
- const response = await fetch(markViewApiUrl, {
414
- method: 'POST',
415
- headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-Token': csrfToken },
416
- body: formData.toString(),
417
- credentials: 'same-origin'
418
- });
419
-
420
- if (response.ok) {
421
- for (const card of unreadCards) {
422
- card.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
423
- card.style.opacity = '0';
424
- card.style.transform = 'translateX(-20px)';
425
- }
426
- setTimeout(() => {
427
- for (const card of [...unreadCards]) card.remove();
428
- markViewBtn.disabled = false;
429
-
430
- // Trigger infinite scroll to load next batch
431
- if (typeof window.__microsubLoadMore === 'function') {
432
- window.__microsubLoadMore();
433
- } else if (timeline.querySelectorAll('.ms-item-card').length === 0) {
434
- location.reload();
435
- }
436
- }, 300);
437
- } else {
438
- markViewBtn.disabled = false;
439
- }
440
- } catch (error) {
441
- console.error('Error marking current view as read:', error);
442
- markViewBtn.disabled = false;
443
- }
444
- });
445
- }
446
- </script>
99
+ <script type="module" src="/assets/@rmdes-indiekit-endpoint-microsub/reader.js"></script>
447
100
  {% endblock %}