@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
@@ -8,7 +8,7 @@
8
8
  {% if channels.length > 0 %}
9
9
  <details class="ms-timeline-view__filter">
10
10
  <summary class="button button--secondary button--small">
11
- Filter channels
11
+ {{ __("microsub.reader.filterChannels") }}
12
12
  </summary>
13
13
  <form action="{{ baseUrl }}/timeline" method="GET" class="ms-timeline-view__filter-form">
14
14
  {% for ch in channels %}
@@ -21,7 +21,7 @@
21
21
  </label>
22
22
  {% endif %}
23
23
  {% endfor %}
24
- <button type="submit" class="button button--primary button--small">Apply</button>
24
+ <button type="submit" class="button button--primary button--small">{{ __("microsub.reader.apply") }}</button>
25
25
  </form>
26
26
  </details>
27
27
  {% endif %}
@@ -65,276 +65,5 @@
65
65
  {% endif %}
66
66
  </div>
67
67
 
68
- <script type="module">
69
- // CSRF token for AJAX requests
70
- const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
71
-
72
- const timeline = document.getElementById('timeline');
73
- if (timeline) {
74
- const items = Array.from(timeline.querySelectorAll('.ms-item-card'));
75
- let currentIndex = -1;
76
-
77
- function focusItem(index) {
78
- if (items[currentIndex]) items[currentIndex].classList.remove('ms-item-card--focused');
79
- currentIndex = Math.max(0, Math.min(index, items.length - 1));
80
- if (items[currentIndex]) {
81
- items[currentIndex].classList.add('ms-item-card--focused');
82
- items[currentIndex].scrollIntoView({ behavior: 'smooth', block: 'center' });
83
- }
84
- }
85
-
86
- document.addEventListener('keydown', (e) => {
87
- if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
88
- switch(e.key) {
89
- case 'j': e.preventDefault(); focusItem(currentIndex + 1); break;
90
- case 'k': e.preventDefault(); focusItem(currentIndex - 1); break;
91
- case 'o': case 'Enter':
92
- e.preventDefault();
93
- if (items[currentIndex]) {
94
- const link = items[currentIndex].querySelector('.ms-item-card__link');
95
- if (link) link.click();
96
- }
97
- break;
98
- }
99
- });
100
-
101
- // Handle individual mark-read buttons
102
- const microsubApiUrl = '{{ baseUrl }}'.replace(/\/reader$/, '');
103
-
104
- timeline.addEventListener('click', async (e) => {
105
- const button = e.target.closest('.ms-item-actions__mark-read');
106
- if (!button) return;
107
-
108
- e.preventDefault();
109
- e.stopPropagation();
110
-
111
- const itemId = button.dataset.itemId;
112
- const channelUid = button.dataset.channelUid;
113
- const channelId = button.dataset.channelId;
114
- if (!itemId || (!channelUid && !channelId)) return;
115
-
116
- button.disabled = true;
117
-
118
- try {
119
- const formData = new URLSearchParams();
120
- formData.append('action', 'timeline');
121
- formData.append('method', 'mark_read');
122
- formData.append('channel', channelUid || channelId);
123
- formData.append('entry', itemId);
124
-
125
- const response = await fetch(microsubApiUrl, {
126
- method: 'POST',
127
- headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-Token': csrfToken },
128
- body: formData.toString(),
129
- credentials: 'same-origin'
130
- });
131
-
132
- if (response.ok) {
133
- const card = button.closest('.ms-item-card');
134
- if (card) {
135
- card.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
136
- card.style.opacity = '0';
137
- card.style.transform = 'translateX(-20px)';
138
- setTimeout(() => {
139
- const wrapper = card.closest('.ms-timeline-view__item');
140
- if (wrapper) wrapper.remove();
141
- else card.remove();
142
- if (timeline.querySelectorAll('.ms-item-card').length === 0) {
143
- location.reload();
144
- }
145
- }, 300);
146
- }
147
- } else {
148
- console.error('Failed to mark item as read');
149
- button.disabled = false;
150
- }
151
- } catch (error) {
152
- console.error('Error marking item as read:', error);
153
- button.disabled = false;
154
- }
155
- });
156
-
157
- // Handle caret toggle for mark-source-read popover
158
- timeline.addEventListener('click', (e) => {
159
- const caret = e.target.closest('.ms-item-actions__mark-read-caret');
160
- if (!caret) return;
161
-
162
- e.preventDefault();
163
- e.stopPropagation();
164
-
165
- // Close other open popovers
166
- for (const p of timeline.querySelectorAll('.ms-item-actions__mark-read-popover:not([hidden])')) {
167
- if (p !== caret.nextElementSibling) p.hidden = true;
168
- }
169
-
170
- const popover = caret.nextElementSibling;
171
- if (popover) popover.hidden = !popover.hidden;
172
- });
173
-
174
- // Handle mark-source-read button
175
- timeline.addEventListener('click', async (e) => {
176
- const button = e.target.closest('.ms-item-actions__mark-source-read');
177
- if (!button) return;
178
-
179
- e.preventDefault();
180
- e.stopPropagation();
181
-
182
- const feedId = button.dataset.feedId;
183
- const channelUid = button.dataset.channelUid;
184
- const channelId = button.dataset.channelId;
185
- if (!feedId || (!channelUid && !channelId)) return;
186
-
187
- button.disabled = true;
188
-
189
- try {
190
- const formData = new URLSearchParams();
191
- formData.append('action', 'timeline');
192
- formData.append('method', 'mark_read_source');
193
- formData.append('channel', channelUid || channelId);
194
- formData.append('feed', feedId);
195
-
196
- const response = await fetch(microsubApiUrl, {
197
- method: 'POST',
198
- headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-Token': csrfToken },
199
- body: formData.toString(),
200
- credentials: 'same-origin'
201
- });
202
-
203
- if (response.ok) {
204
- // Animate out all cards from this feed
205
- const cards = timeline.querySelectorAll(`.ms-item-card[data-feed-id="${feedId}"]`);
206
- for (const card of cards) {
207
- card.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
208
- card.style.opacity = '0';
209
- card.style.transform = 'translateX(-20px)';
210
- }
211
- setTimeout(() => {
212
- for (const card of [...cards]) {
213
- const wrapper = card.closest('.ms-timeline-view__item');
214
- if (wrapper) wrapper.remove();
215
- else card.remove();
216
- }
217
- if (timeline.querySelectorAll('.ms-item-card').length === 0) {
218
- location.reload();
219
- }
220
- }, 300);
221
- } else {
222
- button.disabled = false;
223
- }
224
- } catch (error) {
225
- console.error('Error marking source as read:', error);
226
- button.disabled = false;
227
- }
228
- });
229
-
230
- // Close popovers on outside click
231
- document.addEventListener('click', (e) => {
232
- if (!e.target.closest('.ms-item-actions__mark-read-group')) {
233
- for (const p of timeline.querySelectorAll('.ms-item-actions__mark-read-popover:not([hidden])')) {
234
- p.hidden = true;
235
- }
236
- }
237
- });
238
-
239
- // Handle save-for-later buttons
240
- timeline.addEventListener('click', async (e) => {
241
- const button = e.target.closest('.ms-item-actions__save-later');
242
- if (!button) return;
243
-
244
- e.preventDefault();
245
- e.stopPropagation();
246
-
247
- const url = button.dataset.url;
248
- const title = button.dataset.title;
249
- if (!url) return;
250
-
251
- button.disabled = true;
252
-
253
- try {
254
- const response = await fetch('/readlater/save', {
255
- method: 'POST',
256
- headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
257
- body: JSON.stringify({ url, title: title || url, source: 'microsub' }),
258
- credentials: 'same-origin'
259
- });
260
-
261
- if (response.ok) {
262
- button.classList.add('ms-item-actions__save-later--saved');
263
- button.title = 'Saved';
264
- } else {
265
- button.disabled = false;
266
- }
267
- } catch {
268
- button.disabled = false;
269
- }
270
- });
271
-
272
- // === Infinite scroll ===
273
- const loader = document.getElementById('timeline-loader');
274
- if (loader && timeline) {
275
- const spinner = loader.querySelector('.ms-timeline__spinner');
276
- const loadMoreLink = loader.querySelector('.ms-timeline__load-more');
277
- const endMessage = loader.querySelector('.ms-timeline__end');
278
- let cursor = loader.dataset.cursor;
279
- let loading = false;
280
- let hasMore = true;
281
- const apiUrl = loader.dataset.apiUrl;
282
-
283
- async function loadMore() {
284
- if (loading || !hasMore) return;
285
- loading = true;
286
- if (spinner) spinner.style.display = '';
287
- if (loadMoreLink) loadMoreLink.style.display = 'none';
288
-
289
- try {
290
- const sep = apiUrl.includes('?') ? '&' : '?';
291
- const response = await fetch(`${apiUrl}${sep}after=${encodeURIComponent(cursor)}`, {
292
- credentials: 'same-origin'
293
- });
294
-
295
- if (!response.ok) throw new Error('Failed to load');
296
-
297
- const data = await response.json();
298
-
299
- if (data.html && data.count > 0) {
300
- timeline.insertAdjacentHTML('beforeend', data.html);
301
- }
302
-
303
- if (data.paging?.after) {
304
- cursor = data.paging.after;
305
- if (loadMoreLink) {
306
- loadMoreLink.href = `?after=${cursor}`;
307
- loadMoreLink.style.display = '';
308
- }
309
- } else {
310
- hasMore = false;
311
- if (loadMoreLink) loadMoreLink.style.display = 'none';
312
- if (endMessage) endMessage.style.display = '';
313
- }
314
- } catch (error) {
315
- console.error('Infinite scroll error:', error);
316
- hasMore = false;
317
- if (loadMoreLink) loadMoreLink.style.display = '';
318
- } finally {
319
- loading = false;
320
- if (spinner) spinner.style.display = 'none';
321
- }
322
- }
323
-
324
- const observer = new IntersectionObserver((entries) => {
325
- if (entries[0].isIntersecting && hasMore && !loading) {
326
- loadMore();
327
- }
328
- }, { rootMargin: '200px' });
329
- observer.observe(loader);
330
-
331
- if (loadMoreLink) {
332
- loadMoreLink.addEventListener('click', (e) => {
333
- e.preventDefault();
334
- loadMore();
335
- });
336
- }
337
- }
338
- }
339
- </script>
68
+ <script type="module" src="/assets/@rmdes-indiekit-endpoint-microsub/reader.js"></script>
340
69
  {% endblock %}