@rmdes/indiekit-endpoint-microsub 1.0.50 → 1.0.51
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/styles.css +34 -0
- package/index.js +3 -0
- package/lib/controllers/reader.js +180 -0
- package/locales/en.json +1 -0
- package/package.json +1 -1
- package/views/channel.njk +165 -14
- package/views/timeline.njk +79 -13
package/assets/styles.css
CHANGED
|
@@ -1477,3 +1477,37 @@
|
|
|
1477
1477
|
display: flex;
|
|
1478
1478
|
gap: var(--space-xs);
|
|
1479
1479
|
}
|
|
1480
|
+
|
|
1481
|
+
/* Infinite scroll */
|
|
1482
|
+
.ms-timeline__loader {
|
|
1483
|
+
display: flex;
|
|
1484
|
+
flex-direction: column;
|
|
1485
|
+
align-items: center;
|
|
1486
|
+
padding: 1.5rem;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
.ms-timeline__spinner {
|
|
1490
|
+
display: flex;
|
|
1491
|
+
justify-content: center;
|
|
1492
|
+
padding: 1rem;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
.ms-timeline__spinner .spinner {
|
|
1496
|
+
width: 24px;
|
|
1497
|
+
height: 24px;
|
|
1498
|
+
border: 3px solid rgba(255, 255, 255, 0.2);
|
|
1499
|
+
border-top-color: #3b82f6;
|
|
1500
|
+
border-radius: 50%;
|
|
1501
|
+
animation: ms-spin 0.8s linear infinite;
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
@keyframes ms-spin {
|
|
1505
|
+
to { transform: rotate(360deg); }
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
.ms-timeline__end {
|
|
1509
|
+
text-align: center;
|
|
1510
|
+
color: #888;
|
|
1511
|
+
font-style: italic;
|
|
1512
|
+
padding: 1rem;
|
|
1513
|
+
}
|
package/index.js
CHANGED
|
@@ -92,6 +92,7 @@ export default class MicrosubEndpoint {
|
|
|
92
92
|
readerRouter.get("/channels", readerController.channels);
|
|
93
93
|
readerRouter.get("/channels/new", readerController.newChannel);
|
|
94
94
|
readerRouter.post("/channels/new", readerController.createChannel);
|
|
95
|
+
readerRouter.get("/channels/:uid/html", readerController.channelHtml);
|
|
95
96
|
readerRouter.get("/channels/:uid", readerController.channel);
|
|
96
97
|
readerRouter.get("/channels/:uid/settings", readerController.settings);
|
|
97
98
|
readerRouter.post(
|
|
@@ -135,7 +136,9 @@ export default class MicrosubEndpoint {
|
|
|
135
136
|
readerRouter.post("/actor/follow", readerController.followActorAction);
|
|
136
137
|
readerRouter.post("/actor/unfollow", readerController.unfollowActorAction);
|
|
137
138
|
readerRouter.post("/api/mark-read", readerController.markAllRead);
|
|
139
|
+
readerRouter.post("/api/mark-view-read", readerController.markViewRead);
|
|
138
140
|
readerRouter.get("/opml", opmlController.exportOpml);
|
|
141
|
+
readerRouter.get("/timeline/html", readerController.timelineHtml);
|
|
139
142
|
readerRouter.get("/timeline", readerController.timeline);
|
|
140
143
|
readerRouter.get("/deck", readerController.deck);
|
|
141
144
|
readerRouter.get("/deck/settings", readerController.deckSettings);
|
|
@@ -868,6 +868,183 @@ export async function markAllRead(request, response) {
|
|
|
868
868
|
response.redirect(`${request.baseUrl}/channels/${channelUid}`);
|
|
869
869
|
}
|
|
870
870
|
|
|
871
|
+
/**
|
|
872
|
+
* Return rendered HTML fragments for infinite scroll
|
|
873
|
+
* @param {object} request - Express request
|
|
874
|
+
* @param {object} response - Express response
|
|
875
|
+
* @returns {Promise<void>}
|
|
876
|
+
*/
|
|
877
|
+
export async function channelHtml(request, response) {
|
|
878
|
+
const { application } = request.app.locals;
|
|
879
|
+
const userId = getUserId(request);
|
|
880
|
+
const { uid } = request.params;
|
|
881
|
+
const { before, after, showRead } = request.query;
|
|
882
|
+
|
|
883
|
+
const channelDocument = await getChannel(application, uid, userId);
|
|
884
|
+
if (!channelDocument) {
|
|
885
|
+
return response.status(404).json({ error: "Channel not found" });
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const showReadItems = showRead === "true";
|
|
889
|
+
|
|
890
|
+
const timeline = await getTimelineItems(application, channelDocument._id, {
|
|
891
|
+
before,
|
|
892
|
+
after,
|
|
893
|
+
userId,
|
|
894
|
+
showRead: showReadItems,
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
// Proxy images
|
|
898
|
+
const proxyBaseUrl = application.url;
|
|
899
|
+
if (proxyBaseUrl && timeline.items) {
|
|
900
|
+
timeline.items = timeline.items.map((item) =>
|
|
901
|
+
proxyItemImages(item, proxyBaseUrl),
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// Render each item card to HTML using the existing partial
|
|
906
|
+
const renderLocals = {
|
|
907
|
+
channel: channelDocument,
|
|
908
|
+
baseUrl: request.baseUrl,
|
|
909
|
+
__: request.__,
|
|
910
|
+
icon: response.locals.icon,
|
|
911
|
+
application,
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
const htmlParts = await Promise.all(
|
|
915
|
+
timeline.items.map(
|
|
916
|
+
(item) =>
|
|
917
|
+
new Promise((resolve, reject) => {
|
|
918
|
+
request.app.render(
|
|
919
|
+
"partials/item-card.njk",
|
|
920
|
+
{ ...renderLocals, item },
|
|
921
|
+
(error, html) => {
|
|
922
|
+
if (error) reject(error);
|
|
923
|
+
else resolve(html);
|
|
924
|
+
},
|
|
925
|
+
);
|
|
926
|
+
}),
|
|
927
|
+
),
|
|
928
|
+
);
|
|
929
|
+
|
|
930
|
+
response.json({
|
|
931
|
+
html: htmlParts.join(""),
|
|
932
|
+
paging: timeline.paging,
|
|
933
|
+
count: timeline.items.length,
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* Return rendered HTML fragments for timeline infinite scroll
|
|
939
|
+
* @param {object} request - Express request
|
|
940
|
+
* @param {object} response - Express response
|
|
941
|
+
* @returns {Promise<void>}
|
|
942
|
+
*/
|
|
943
|
+
export async function timelineHtml(request, response) {
|
|
944
|
+
const { application } = request.app.locals;
|
|
945
|
+
const userId = getUserId(request);
|
|
946
|
+
const { before, after } = request.query;
|
|
947
|
+
|
|
948
|
+
const channelList = await getChannelsWithColors(application, userId);
|
|
949
|
+
const channelMap = new Map();
|
|
950
|
+
for (const ch of channelList) {
|
|
951
|
+
channelMap.set(ch._id.toString(), { name: ch.name, color: ch.color, uid: ch.uid });
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const excludeParam = request.query.exclude;
|
|
955
|
+
const excludeIds = excludeParam
|
|
956
|
+
? (Array.isArray(excludeParam) ? excludeParam : [excludeParam])
|
|
957
|
+
: [];
|
|
958
|
+
|
|
959
|
+
const notificationsChannel = channelList.find((ch) => ch.uid === "notifications");
|
|
960
|
+
const excludeChannelIds = [...excludeIds];
|
|
961
|
+
if (notificationsChannel && !excludeChannelIds.includes(notificationsChannel._id.toString())) {
|
|
962
|
+
excludeChannelIds.push(notificationsChannel._id.toString());
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
const result = await getAllTimelineItems(application, {
|
|
966
|
+
before,
|
|
967
|
+
after,
|
|
968
|
+
userId,
|
|
969
|
+
excludeChannelIds,
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
const proxyBaseUrl = application.url;
|
|
973
|
+
if (proxyBaseUrl && result.items) {
|
|
974
|
+
result.items = result.items.map((item) => proxyItemImages(item, proxyBaseUrl));
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
for (const item of result.items) {
|
|
978
|
+
if (item._channelId) {
|
|
979
|
+
const info = channelMap.get(item._channelId);
|
|
980
|
+
if (info) {
|
|
981
|
+
item._channelName = info.name;
|
|
982
|
+
item._channelColor = info.color;
|
|
983
|
+
item._channelUid = info.uid;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
const renderLocals = {
|
|
989
|
+
baseUrl: request.baseUrl,
|
|
990
|
+
__: request.__,
|
|
991
|
+
icon: response.locals.icon,
|
|
992
|
+
application,
|
|
993
|
+
};
|
|
994
|
+
|
|
995
|
+
const htmlParts = await Promise.all(
|
|
996
|
+
result.items.map(
|
|
997
|
+
(item) =>
|
|
998
|
+
new Promise((resolve, reject) => {
|
|
999
|
+
request.app.render(
|
|
1000
|
+
"partials/item-card.njk",
|
|
1001
|
+
{ ...renderLocals, item },
|
|
1002
|
+
(error, cardHtml) => {
|
|
1003
|
+
if (error) {
|
|
1004
|
+
reject(error);
|
|
1005
|
+
} else {
|
|
1006
|
+
const badge = item._channelName
|
|
1007
|
+
? `<span class="ms-timeline-view__channel-badge" style="background: ${item._channelColor || "#888"}">${item._channelName}</span>`
|
|
1008
|
+
: "";
|
|
1009
|
+
resolve(`<div class="ms-timeline-view__item">${badge}${cardHtml}</div>`);
|
|
1010
|
+
}
|
|
1011
|
+
},
|
|
1012
|
+
);
|
|
1013
|
+
}),
|
|
1014
|
+
),
|
|
1015
|
+
);
|
|
1016
|
+
|
|
1017
|
+
response.json({
|
|
1018
|
+
html: htmlParts.join(""),
|
|
1019
|
+
paging: result.paging,
|
|
1020
|
+
count: result.items.length,
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/**
|
|
1025
|
+
* Mark specific items as read (no-JS form fallback for mark-view-as-read)
|
|
1026
|
+
* @param {object} request - Express request
|
|
1027
|
+
* @param {object} response - Express response
|
|
1028
|
+
*/
|
|
1029
|
+
export async function markViewRead(request, response) {
|
|
1030
|
+
const { application } = request.app.locals;
|
|
1031
|
+
const userId = getUserId(request);
|
|
1032
|
+
const { channel: channelUid } = request.body;
|
|
1033
|
+
let { entry } = request.body;
|
|
1034
|
+
|
|
1035
|
+
const channelDocument = await getChannel(application, channelUid, userId);
|
|
1036
|
+
if (!channelDocument) {
|
|
1037
|
+
return response.status(404).render("404");
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
const entryIds = Array.isArray(entry) ? entry : entry ? [entry] : [];
|
|
1041
|
+
if (entryIds.length > 0) {
|
|
1042
|
+
await markItemsRead(application, channelDocument._id, entryIds, userId);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
response.redirect(`${request.baseUrl}/channels/${channelUid}`);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
871
1048
|
/**
|
|
872
1049
|
* View single feed details with status - redirects to edit form
|
|
873
1050
|
* @param {object} request - Express request
|
|
@@ -1409,9 +1586,11 @@ export const readerController = {
|
|
|
1409
1586
|
newChannel,
|
|
1410
1587
|
createChannel: createChannelAction,
|
|
1411
1588
|
channel,
|
|
1589
|
+
channelHtml,
|
|
1412
1590
|
settings,
|
|
1413
1591
|
updateSettings,
|
|
1414
1592
|
markAllRead,
|
|
1593
|
+
markViewRead,
|
|
1415
1594
|
deleteChannel: deleteChannelAction,
|
|
1416
1595
|
feeds,
|
|
1417
1596
|
addFeed,
|
|
@@ -1431,6 +1610,7 @@ export const readerController = {
|
|
|
1431
1610
|
followActorAction,
|
|
1432
1611
|
unfollowActorAction,
|
|
1433
1612
|
timeline,
|
|
1613
|
+
timelineHtml,
|
|
1434
1614
|
deck,
|
|
1435
1615
|
deckSettings,
|
|
1436
1616
|
saveDeckSettings,
|
package/locales/en.json
CHANGED
package/package.json
CHANGED
package/views/channel.njk
CHANGED
|
@@ -13,6 +13,12 @@
|
|
|
13
13
|
{{ icon("tick") }} {{ __("microsub.reader.markAllRead") }}
|
|
14
14
|
</button>
|
|
15
15
|
</form>
|
|
16
|
+
<button type="button"
|
|
17
|
+
class="button button--secondary button--small js-mark-view-read"
|
|
18
|
+
data-channel="{{ channel.uid }}"
|
|
19
|
+
style="display:none">
|
|
20
|
+
{{ icon("tick") }} {{ __("microsub.reader.markViewRead") }}
|
|
21
|
+
</button>
|
|
16
22
|
{% endif %}
|
|
17
23
|
{% if showRead %}
|
|
18
24
|
<a href="{{ baseUrl }}/channels/{{ channel.uid }}" class="button button--secondary button--small">
|
|
@@ -33,27 +39,41 @@
|
|
|
33
39
|
</header>
|
|
34
40
|
|
|
35
41
|
{% if items.length > 0 %}
|
|
42
|
+
<form method="POST" action="{{ baseUrl }}/api/mark-view-read" id="mark-view-form">
|
|
43
|
+
<input type="hidden" name="channel" value="{{ channel.uid }}">
|
|
44
|
+
{% for item in items %}
|
|
45
|
+
{% if not item._is_read %}
|
|
46
|
+
<input type="hidden" name="entry" value="{{ item._id }}">
|
|
47
|
+
{% endif %}
|
|
48
|
+
{% endfor %}
|
|
49
|
+
<noscript>
|
|
50
|
+
<button type="submit" class="button button--secondary button--small">
|
|
51
|
+
{{ icon("tick") }} {{ __("microsub.reader.markViewRead") }}
|
|
52
|
+
</button>
|
|
53
|
+
</noscript>
|
|
54
|
+
</form>
|
|
36
55
|
<div class="ms-timeline" id="timeline" data-channel="{{ channel.uid }}">
|
|
37
56
|
{% for item in items %}
|
|
38
57
|
{% include "partials/item-card.njk" %}
|
|
39
58
|
{% endfor %}
|
|
40
59
|
</div>
|
|
41
60
|
|
|
42
|
-
{
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
{
|
|
52
|
-
|
|
53
|
-
{{ __("microsub.reader.older") }} {{ icon("next") }}
|
|
61
|
+
{# Infinite scroll sentinel — JS upgrades the fallback link #}
|
|
62
|
+
{% if paging and paging.after %}
|
|
63
|
+
<div class="ms-timeline__loader" id="timeline-loader"
|
|
64
|
+
data-api-url="{{ baseUrl }}/channels/{{ channel.uid }}/html"
|
|
65
|
+
data-cursor="{{ paging.after }}"
|
|
66
|
+
data-show-read="{% if showRead %}true{% else %}false{% endif %}">
|
|
67
|
+
<div class="ms-timeline__spinner" style="display:none" aria-label="Loading more items">
|
|
68
|
+
<span class="spinner"></span>
|
|
69
|
+
</div>
|
|
70
|
+
<a href="?after={{ paging.after }}{% if showRead %}&showRead=true{% endif %}" class="button button--secondary ms-timeline__load-more">
|
|
71
|
+
{{ __("microsub.reader.older") }}
|
|
54
72
|
</a>
|
|
55
|
-
|
|
56
|
-
|
|
73
|
+
<p class="ms-timeline__end" style="display:none">
|
|
74
|
+
{{ __("microsub.reader.allRead") }}
|
|
75
|
+
</p>
|
|
76
|
+
</div>
|
|
57
77
|
{% endif %}
|
|
58
78
|
{% else %}
|
|
59
79
|
<div class="ms-reader__empty">
|
|
@@ -285,5 +305,136 @@
|
|
|
285
305
|
}
|
|
286
306
|
});
|
|
287
307
|
}
|
|
308
|
+
|
|
309
|
+
// === Infinite scroll ===
|
|
310
|
+
const loader = document.getElementById('timeline-loader');
|
|
311
|
+
if (loader && timeline) {
|
|
312
|
+
const spinner = loader.querySelector('.ms-timeline__spinner');
|
|
313
|
+
const loadMoreLink = loader.querySelector('.ms-timeline__load-more');
|
|
314
|
+
const endMessage = loader.querySelector('.ms-timeline__end');
|
|
315
|
+
let cursor = loader.dataset.cursor;
|
|
316
|
+
let loading = false;
|
|
317
|
+
let hasMore = true;
|
|
318
|
+
const apiUrl = loader.dataset.apiUrl;
|
|
319
|
+
const showReadParam = loader.dataset.showRead;
|
|
320
|
+
|
|
321
|
+
async function loadMore() {
|
|
322
|
+
if (loading || !hasMore) return;
|
|
323
|
+
loading = true;
|
|
324
|
+
if (spinner) spinner.style.display = '';
|
|
325
|
+
if (loadMoreLink) loadMoreLink.style.display = 'none';
|
|
326
|
+
|
|
327
|
+
try {
|
|
328
|
+
const params = new URLSearchParams({ after: cursor });
|
|
329
|
+
if (showReadParam === 'true') params.append('showRead', 'true');
|
|
330
|
+
|
|
331
|
+
const response = await fetch(`${apiUrl}?${params}`, {
|
|
332
|
+
credentials: 'same-origin'
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
if (!response.ok) throw new Error('Failed to load');
|
|
336
|
+
|
|
337
|
+
const data = await response.json();
|
|
338
|
+
|
|
339
|
+
if (data.html && data.count > 0) {
|
|
340
|
+
timeline.insertAdjacentHTML('beforeend', data.html);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (data.paging?.after) {
|
|
344
|
+
cursor = data.paging.after;
|
|
345
|
+
if (loadMoreLink) {
|
|
346
|
+
loadMoreLink.href = `?after=${cursor}${showReadParam === 'true' ? '&showRead=true' : ''}`;
|
|
347
|
+
loadMoreLink.style.display = '';
|
|
348
|
+
}
|
|
349
|
+
} else {
|
|
350
|
+
hasMore = false;
|
|
351
|
+
if (loadMoreLink) loadMoreLink.style.display = 'none';
|
|
352
|
+
if (endMessage) endMessage.style.display = '';
|
|
353
|
+
}
|
|
354
|
+
} catch (error) {
|
|
355
|
+
console.error('Infinite scroll error:', error);
|
|
356
|
+
hasMore = false;
|
|
357
|
+
if (loadMoreLink) loadMoreLink.style.display = '';
|
|
358
|
+
} finally {
|
|
359
|
+
loading = false;
|
|
360
|
+
if (spinner) spinner.style.display = 'none';
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// IntersectionObserver auto-loads when sentinel is visible
|
|
365
|
+
const observer = new IntersectionObserver((entries) => {
|
|
366
|
+
if (entries[0].isIntersecting && hasMore && !loading) {
|
|
367
|
+
loadMore();
|
|
368
|
+
}
|
|
369
|
+
}, { rootMargin: '200px' });
|
|
370
|
+
observer.observe(loader);
|
|
371
|
+
|
|
372
|
+
// Click handler for fallback link
|
|
373
|
+
if (loadMoreLink) {
|
|
374
|
+
loadMoreLink.addEventListener('click', (e) => {
|
|
375
|
+
e.preventDefault();
|
|
376
|
+
loadMore();
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Expose loadMore for mark-view-as-read to trigger
|
|
381
|
+
window.__microsubLoadMore = loadMore;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// === Mark view as read button ===
|
|
385
|
+
const markViewBtn = document.querySelector('.js-mark-view-read');
|
|
386
|
+
if (markViewBtn && timeline) {
|
|
387
|
+
markViewBtn.style.display = '';
|
|
388
|
+
|
|
389
|
+
markViewBtn.addEventListener('click', async () => {
|
|
390
|
+
const unreadCards = timeline.querySelectorAll('.ms-item-card:not(.ms-item-card--read)');
|
|
391
|
+
const itemIds = [...unreadCards].map((card) => card.dataset.itemId).filter(Boolean);
|
|
392
|
+
|
|
393
|
+
if (itemIds.length === 0) return;
|
|
394
|
+
|
|
395
|
+
markViewBtn.disabled = true;
|
|
396
|
+
|
|
397
|
+
const formData = new URLSearchParams();
|
|
398
|
+
formData.append('action', 'timeline');
|
|
399
|
+
formData.append('method', 'mark_read');
|
|
400
|
+
formData.append('channel', markViewBtn.dataset.channel);
|
|
401
|
+
for (const id of itemIds) {
|
|
402
|
+
formData.append('entry', id);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
try {
|
|
406
|
+
const response = await fetch(microsubApiUrl, {
|
|
407
|
+
method: 'POST',
|
|
408
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
409
|
+
body: formData.toString(),
|
|
410
|
+
credentials: 'same-origin'
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
if (response.ok) {
|
|
414
|
+
for (const card of unreadCards) {
|
|
415
|
+
card.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
|
|
416
|
+
card.style.opacity = '0';
|
|
417
|
+
card.style.transform = 'translateX(-20px)';
|
|
418
|
+
}
|
|
419
|
+
setTimeout(() => {
|
|
420
|
+
for (const card of [...unreadCards]) card.remove();
|
|
421
|
+
markViewBtn.disabled = false;
|
|
422
|
+
|
|
423
|
+
// Trigger infinite scroll to load next batch
|
|
424
|
+
if (typeof window.__microsubLoadMore === 'function') {
|
|
425
|
+
window.__microsubLoadMore();
|
|
426
|
+
} else if (timeline.querySelectorAll('.ms-item-card').length === 0) {
|
|
427
|
+
location.reload();
|
|
428
|
+
}
|
|
429
|
+
}, 300);
|
|
430
|
+
} else {
|
|
431
|
+
markViewBtn.disabled = false;
|
|
432
|
+
}
|
|
433
|
+
} catch (error) {
|
|
434
|
+
console.error('Error marking view as read:', error);
|
|
435
|
+
markViewBtn.disabled = false;
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
}
|
|
288
439
|
</script>
|
|
289
440
|
{% endblock %}
|
package/views/timeline.njk
CHANGED
|
@@ -42,21 +42,20 @@
|
|
|
42
42
|
{% endfor %}
|
|
43
43
|
</div>
|
|
44
44
|
|
|
45
|
-
{% if paging %}
|
|
46
|
-
<
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
<
|
|
53
|
-
{% endif %}
|
|
54
|
-
{% if paging.after %}
|
|
55
|
-
<a href="?after={{ paging.after }}" class="button button--secondary">
|
|
45
|
+
{% if paging and paging.after %}
|
|
46
|
+
<div class="ms-timeline__loader" id="timeline-loader"
|
|
47
|
+
data-api-url="{{ baseUrl }}/timeline/html{% if excludeIds and excludeIds.length > 0 %}?{% for id in excludeIds %}exclude={{ id }}{% if not loop.last %}&{% endif %}{% endfor %}{% endif %}"
|
|
48
|
+
data-cursor="{{ paging.after }}">
|
|
49
|
+
<div class="ms-timeline__spinner" style="display:none" aria-label="Loading more items">
|
|
50
|
+
<span class="spinner"></span>
|
|
51
|
+
</div>
|
|
52
|
+
<a href="?after={{ paging.after }}" class="button button--secondary ms-timeline__load-more">
|
|
56
53
|
{{ __("microsub.reader.older") }}
|
|
57
54
|
</a>
|
|
58
|
-
|
|
59
|
-
|
|
55
|
+
<p class="ms-timeline__end" style="display:none">
|
|
56
|
+
{{ __("microsub.reader.allRead") }}
|
|
57
|
+
</p>
|
|
58
|
+
</div>
|
|
60
59
|
{% endif %}
|
|
61
60
|
|
|
62
61
|
{% else %}
|
|
@@ -266,6 +265,73 @@
|
|
|
266
265
|
button.disabled = false;
|
|
267
266
|
}
|
|
268
267
|
});
|
|
268
|
+
|
|
269
|
+
// === Infinite scroll ===
|
|
270
|
+
const loader = document.getElementById('timeline-loader');
|
|
271
|
+
if (loader && timeline) {
|
|
272
|
+
const spinner = loader.querySelector('.ms-timeline__spinner');
|
|
273
|
+
const loadMoreLink = loader.querySelector('.ms-timeline__load-more');
|
|
274
|
+
const endMessage = loader.querySelector('.ms-timeline__end');
|
|
275
|
+
let cursor = loader.dataset.cursor;
|
|
276
|
+
let loading = false;
|
|
277
|
+
let hasMore = true;
|
|
278
|
+
const apiUrl = loader.dataset.apiUrl;
|
|
279
|
+
|
|
280
|
+
async function loadMore() {
|
|
281
|
+
if (loading || !hasMore) return;
|
|
282
|
+
loading = true;
|
|
283
|
+
if (spinner) spinner.style.display = '';
|
|
284
|
+
if (loadMoreLink) loadMoreLink.style.display = 'none';
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
const sep = apiUrl.includes('?') ? '&' : '?';
|
|
288
|
+
const response = await fetch(`${apiUrl}${sep}after=${encodeURIComponent(cursor)}`, {
|
|
289
|
+
credentials: 'same-origin'
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
if (!response.ok) throw new Error('Failed to load');
|
|
293
|
+
|
|
294
|
+
const data = await response.json();
|
|
295
|
+
|
|
296
|
+
if (data.html && data.count > 0) {
|
|
297
|
+
timeline.insertAdjacentHTML('beforeend', data.html);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (data.paging?.after) {
|
|
301
|
+
cursor = data.paging.after;
|
|
302
|
+
if (loadMoreLink) {
|
|
303
|
+
loadMoreLink.href = `?after=${cursor}`;
|
|
304
|
+
loadMoreLink.style.display = '';
|
|
305
|
+
}
|
|
306
|
+
} else {
|
|
307
|
+
hasMore = false;
|
|
308
|
+
if (loadMoreLink) loadMoreLink.style.display = 'none';
|
|
309
|
+
if (endMessage) endMessage.style.display = '';
|
|
310
|
+
}
|
|
311
|
+
} catch (error) {
|
|
312
|
+
console.error('Infinite scroll error:', error);
|
|
313
|
+
hasMore = false;
|
|
314
|
+
if (loadMoreLink) loadMoreLink.style.display = '';
|
|
315
|
+
} finally {
|
|
316
|
+
loading = false;
|
|
317
|
+
if (spinner) spinner.style.display = 'none';
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const observer = new IntersectionObserver((entries) => {
|
|
322
|
+
if (entries[0].isIntersecting && hasMore && !loading) {
|
|
323
|
+
loadMore();
|
|
324
|
+
}
|
|
325
|
+
}, { rootMargin: '200px' });
|
|
326
|
+
observer.observe(loader);
|
|
327
|
+
|
|
328
|
+
if (loadMoreLink) {
|
|
329
|
+
loadMoreLink.addEventListener('click', (e) => {
|
|
330
|
+
e.preventDefault();
|
|
331
|
+
loadMore();
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
269
335
|
}
|
|
270
336
|
</script>
|
|
271
337
|
{% endblock %}
|