@rmdes/indiekit-endpoint-microsub 1.0.56 → 1.0.57
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/assets/reader.js +408 -0
- package/index.js +37 -36
- package/lib/cache/redis.js +12 -3
- package/lib/controllers/reader/actor.js +142 -0
- package/lib/controllers/reader/channel.js +301 -0
- package/lib/controllers/reader/compose.js +242 -0
- package/lib/controllers/reader/deck.js +129 -0
- package/lib/controllers/reader/feed-repair.js +117 -0
- package/lib/controllers/reader/feed.js +246 -0
- package/lib/controllers/reader/index.js +126 -0
- package/lib/controllers/reader/search.js +157 -0
- package/lib/controllers/reader/timeline.js +250 -0
- package/lib/controllers/timeline.js +4 -2
- package/lib/feeds/atom.js +1 -1
- package/lib/feeds/fetcher.js +1 -30
- package/lib/feeds/hfeed.js +1 -1
- package/lib/feeds/jsonfeed.js +1 -1
- package/lib/feeds/normalizer-hfeed.js +209 -0
- package/lib/feeds/normalizer-jsonfeed.js +171 -0
- package/lib/feeds/normalizer-rss.js +178 -0
- package/lib/feeds/normalizer.js +20 -560
- package/lib/feeds/rss.js +1 -1
- package/lib/polling/processor.js +3 -17
- package/lib/storage/items-read-state.js +287 -0
- package/lib/storage/items-retention.js +174 -0
- package/lib/storage/items-search.js +34 -0
- package/lib/storage/items.js +99 -590
- package/lib/storage/read-state.js +1 -1
- package/lib/utils/async-handler.js +7 -0
- package/lib/utils/html.js +25 -0
- package/lib/utils/source-type.js +28 -0
- package/lib/webmention/processor.js +1 -1
- package/locales/de.json +3 -0
- package/locales/en.json +2 -0
- package/locales/es-419.json +3 -0
- package/locales/es.json +3 -0
- package/locales/fr.json +3 -0
- package/locales/hi.json +3 -0
- package/locales/id.json +3 -0
- package/locales/it.json +3 -0
- package/locales/nl.json +3 -0
- package/locales/pl.json +3 -0
- package/locales/pt-BR.json +3 -0
- package/locales/pt.json +3 -0
- package/locales/sr.json +3 -0
- package/locales/sv.json +3 -0
- package/locales/zh-Hans-CN.json +3 -0
- package/package.json +1 -1
- package/views/channel.njk +1 -348
- package/views/timeline.njk +3 -274
- package/lib/controllers/reader.js +0 -1562
|
@@ -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!",
|
package/locales/es-419.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/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!",
|
package/locales/pt-BR.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/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!",
|
package/locales/zh-Hans-CN.json
CHANGED
package/package.json
CHANGED
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 %}
|