@rmdes/indiekit-endpoint-microsub 1.0.44 → 1.0.45
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 +51 -1
- package/lib/controllers/timeline.js +17 -0
- package/lib/storage/items.js +36 -0
- package/package.json +1 -1
- package/views/channel.njk +78 -0
- package/views/partials/item-card.njk +29 -0
- package/views/timeline.njk +82 -0
package/assets/styles.css
CHANGED
|
@@ -416,7 +416,57 @@
|
|
|
416
416
|
color: var(--color-background);
|
|
417
417
|
}
|
|
418
418
|
|
|
419
|
-
/* Mark as read button */
|
|
419
|
+
/* Mark as read — split button group */
|
|
420
|
+
.item-actions__mark-read-group {
|
|
421
|
+
display: inline-flex;
|
|
422
|
+
margin-left: auto;
|
|
423
|
+
position: relative;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
.item-actions__mark-read-group .item-actions__mark-read {
|
|
427
|
+
border-bottom-right-radius: 0;
|
|
428
|
+
border-right: 0;
|
|
429
|
+
border-top-right-radius: 0;
|
|
430
|
+
margin-left: 0;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
.item-actions__mark-read-caret {
|
|
434
|
+
border-bottom-left-radius: 0;
|
|
435
|
+
border-top-left-radius: 0;
|
|
436
|
+
font-size: 0.625rem;
|
|
437
|
+
padding: var(--space-xs) 6px;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
.item-actions__mark-read-popover {
|
|
441
|
+
background: var(--color-background);
|
|
442
|
+
border: 1px solid var(--color-offset-active);
|
|
443
|
+
border-radius: var(--border-radius);
|
|
444
|
+
bottom: calc(100% + 4px);
|
|
445
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
|
446
|
+
padding: var(--space-xs);
|
|
447
|
+
position: absolute;
|
|
448
|
+
right: 0;
|
|
449
|
+
white-space: nowrap;
|
|
450
|
+
z-index: 10;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
.item-actions__mark-source-read {
|
|
454
|
+
background: transparent;
|
|
455
|
+
border: 0;
|
|
456
|
+
border-radius: var(--border-radius);
|
|
457
|
+
color: var(--color-text);
|
|
458
|
+
cursor: pointer;
|
|
459
|
+
font-size: var(--font-size-small);
|
|
460
|
+
padding: var(--space-xs) var(--space-s);
|
|
461
|
+
text-align: left;
|
|
462
|
+
width: 100%;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
.item-actions__mark-source-read:hover {
|
|
466
|
+
background: var(--color-offset);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/* Mark as read button (standalone, no split group) */
|
|
420
470
|
.item-actions__mark-read {
|
|
421
471
|
margin-left: auto;
|
|
422
472
|
}
|
|
@@ -9,6 +9,7 @@ import { proxyItemImages } from "../media/proxy.js";
|
|
|
9
9
|
import { getChannel, getChannelById } from "../storage/channels.js";
|
|
10
10
|
import {
|
|
11
11
|
getTimelineItems,
|
|
12
|
+
markFeedItemsRead,
|
|
12
13
|
markItemsRead,
|
|
13
14
|
markItemsUnread,
|
|
14
15
|
removeItems,
|
|
@@ -103,6 +104,22 @@ export async function action(request, response) {
|
|
|
103
104
|
return response.json({ result: "ok", updated: count });
|
|
104
105
|
}
|
|
105
106
|
|
|
107
|
+
case "mark_read_source": {
|
|
108
|
+
const feedId = request.body.feed;
|
|
109
|
+
if (!feedId) {
|
|
110
|
+
throw new IndiekitError("feed parameter required", {
|
|
111
|
+
status: 400,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
const count = await markFeedItemsRead(
|
|
115
|
+
application,
|
|
116
|
+
channelDocument._id,
|
|
117
|
+
feedId,
|
|
118
|
+
userId,
|
|
119
|
+
);
|
|
120
|
+
return response.json({ result: "ok", updated: count });
|
|
121
|
+
}
|
|
122
|
+
|
|
106
123
|
case "mark_unread": {
|
|
107
124
|
validateEntries(entries);
|
|
108
125
|
const count = await markItemsUnread(
|
package/lib/storage/items.js
CHANGED
|
@@ -271,6 +271,7 @@ function transformToJf2(item, userId) {
|
|
|
271
271
|
_id: item._id.toString(),
|
|
272
272
|
_is_read: userId ? item.readBy?.includes(userId) : false,
|
|
273
273
|
_channelId: item.channelId?.toString(),
|
|
274
|
+
_feedId: item.feedId?.toString(),
|
|
274
275
|
};
|
|
275
276
|
|
|
276
277
|
// Optional fields
|
|
@@ -695,6 +696,41 @@ export async function markItemsRead(application, channelId, entryIds, userId) {
|
|
|
695
696
|
return result.modifiedCount;
|
|
696
697
|
}
|
|
697
698
|
|
|
699
|
+
/**
|
|
700
|
+
* Mark all items from a specific feed as read in a channel
|
|
701
|
+
* @param {object} application - Indiekit application
|
|
702
|
+
* @param {ObjectId|string} channelId - Channel ObjectId
|
|
703
|
+
* @param {ObjectId|string} feedId - Feed ObjectId
|
|
704
|
+
* @param {string} userId - User ID
|
|
705
|
+
* @returns {Promise<number>} Number of items updated
|
|
706
|
+
*/
|
|
707
|
+
export async function markFeedItemsRead(
|
|
708
|
+
application,
|
|
709
|
+
channelId,
|
|
710
|
+
feedId,
|
|
711
|
+
userId,
|
|
712
|
+
) {
|
|
713
|
+
const collection = getCollection(application);
|
|
714
|
+
const channelObjectId =
|
|
715
|
+
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
|
716
|
+
const feedObjectId =
|
|
717
|
+
typeof feedId === "string" ? new ObjectId(feedId) : feedId;
|
|
718
|
+
|
|
719
|
+
const result = await collection.updateMany(
|
|
720
|
+
{ channelId: channelObjectId, feedId: feedObjectId },
|
|
721
|
+
{ $addToSet: { readBy: userId } },
|
|
722
|
+
);
|
|
723
|
+
|
|
724
|
+
console.info(
|
|
725
|
+
`[Microsub] markFeedItemsRead: marked ${result.modifiedCount} items from feed ${feedId} as read`,
|
|
726
|
+
);
|
|
727
|
+
|
|
728
|
+
// Cleanup old read items
|
|
729
|
+
await cleanupOldReadItems(collection, channelObjectId, userId);
|
|
730
|
+
|
|
731
|
+
return result.modifiedCount;
|
|
732
|
+
}
|
|
733
|
+
|
|
698
734
|
/**
|
|
699
735
|
* Mark items as unread
|
|
700
736
|
* @param {object} application - Indiekit application
|
package/package.json
CHANGED
package/views/channel.njk
CHANGED
|
@@ -174,6 +174,84 @@
|
|
|
174
174
|
}
|
|
175
175
|
});
|
|
176
176
|
|
|
177
|
+
// Handle caret toggle for mark-source-read popover
|
|
178
|
+
timeline.addEventListener('click', (e) => {
|
|
179
|
+
const caret = e.target.closest('.item-actions__mark-read-caret');
|
|
180
|
+
if (!caret) return;
|
|
181
|
+
|
|
182
|
+
e.preventDefault();
|
|
183
|
+
e.stopPropagation();
|
|
184
|
+
|
|
185
|
+
// Close other open popovers
|
|
186
|
+
for (const p of timeline.querySelectorAll('.item-actions__mark-read-popover:not([hidden])')) {
|
|
187
|
+
if (p !== caret.nextElementSibling) p.hidden = true;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const popover = caret.nextElementSibling;
|
|
191
|
+
if (popover) popover.hidden = !popover.hidden;
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// Handle mark-source-read button
|
|
195
|
+
timeline.addEventListener('click', async (e) => {
|
|
196
|
+
const button = e.target.closest('.item-actions__mark-source-read');
|
|
197
|
+
if (!button) return;
|
|
198
|
+
|
|
199
|
+
e.preventDefault();
|
|
200
|
+
e.stopPropagation();
|
|
201
|
+
|
|
202
|
+
const feedId = button.dataset.feedId;
|
|
203
|
+
if (!feedId) return;
|
|
204
|
+
|
|
205
|
+
button.disabled = true;
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
const formData = new URLSearchParams();
|
|
209
|
+
formData.append('action', 'timeline');
|
|
210
|
+
formData.append('method', 'mark_read_source');
|
|
211
|
+
formData.append('channel', channelUid);
|
|
212
|
+
formData.append('feed', feedId);
|
|
213
|
+
|
|
214
|
+
const response = await fetch(microsubApiUrl, {
|
|
215
|
+
method: 'POST',
|
|
216
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
217
|
+
body: formData.toString(),
|
|
218
|
+
credentials: 'same-origin'
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
if (response.ok) {
|
|
222
|
+
// Animate out all cards from this feed
|
|
223
|
+
const cards = timeline.querySelectorAll(`.item-card[data-feed-id="${feedId}"]`);
|
|
224
|
+
for (const card of cards) {
|
|
225
|
+
card.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
|
|
226
|
+
card.style.opacity = '0';
|
|
227
|
+
card.style.transform = 'translateX(-20px)';
|
|
228
|
+
}
|
|
229
|
+
setTimeout(() => {
|
|
230
|
+
for (const card of [...cards]) {
|
|
231
|
+
card.remove();
|
|
232
|
+
}
|
|
233
|
+
if (timeline.querySelectorAll('.item-card').length === 0) {
|
|
234
|
+
location.reload();
|
|
235
|
+
}
|
|
236
|
+
}, 300);
|
|
237
|
+
} else {
|
|
238
|
+
button.disabled = false;
|
|
239
|
+
}
|
|
240
|
+
} catch (error) {
|
|
241
|
+
console.error('Error marking source as read:', error);
|
|
242
|
+
button.disabled = false;
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// Close popovers on outside click
|
|
247
|
+
document.addEventListener('click', (e) => {
|
|
248
|
+
if (!e.target.closest('.item-actions__mark-read-group')) {
|
|
249
|
+
for (const p of timeline.querySelectorAll('.item-actions__mark-read-popover:not([hidden])')) {
|
|
250
|
+
p.hidden = true;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
177
255
|
// Handle save-for-later buttons
|
|
178
256
|
timeline.addEventListener('click', async (e) => {
|
|
179
257
|
const button = e.target.closest('.item-actions__save-later');
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
#}
|
|
5
5
|
<article class="item-card{% if item._is_read %} item-card--read{% endif %}"
|
|
6
6
|
data-item-id="{{ item._id }}"
|
|
7
|
+
data-feed-id="{{ item._feedId or '' }}"
|
|
7
8
|
data-is-read="{{ item._is_read | default(false) }}">
|
|
8
9
|
|
|
9
10
|
{# Context bar for interactions (Aperture pattern) #}
|
|
@@ -198,6 +199,33 @@
|
|
|
198
199
|
<span class="visually-hidden">Bookmark</span>
|
|
199
200
|
</a>
|
|
200
201
|
{% if not item._is_read %}
|
|
202
|
+
{% if item._feedId %}
|
|
203
|
+
<span class="item-actions__mark-read-group">
|
|
204
|
+
<button type="button"
|
|
205
|
+
class="item-actions__button item-actions__mark-read"
|
|
206
|
+
data-action="mark-read"
|
|
207
|
+
data-item-id="{{ item._id }}"
|
|
208
|
+
{% if item._channelUid %}data-channel-uid="{{ item._channelUid }}"{% endif %}
|
|
209
|
+
{% if item._channelId %}data-channel-id="{{ item._channelId }}"{% endif %}
|
|
210
|
+
title="Mark as read">
|
|
211
|
+
{{ icon("checkboxChecked") }}
|
|
212
|
+
<span class="visually-hidden">Mark read</span>
|
|
213
|
+
</button>
|
|
214
|
+
<button type="button"
|
|
215
|
+
class="item-actions__button item-actions__mark-read-caret"
|
|
216
|
+
aria-label="More mark-read options"
|
|
217
|
+
title="More options">▾</button>
|
|
218
|
+
<div class="item-actions__mark-read-popover" hidden>
|
|
219
|
+
<button type="button"
|
|
220
|
+
class="item-actions__mark-source-read"
|
|
221
|
+
data-feed-id="{{ item._feedId }}"
|
|
222
|
+
{% if item._channelUid %}data-channel-uid="{{ item._channelUid }}"{% endif %}
|
|
223
|
+
{% if item._channelId %}data-channel-id="{{ item._channelId }}"{% endif %}>
|
|
224
|
+
Mark {{ item._source.name or item.author.name or "source" }} as read
|
|
225
|
+
</button>
|
|
226
|
+
</div>
|
|
227
|
+
</span>
|
|
228
|
+
{% else %}
|
|
201
229
|
<button type="button"
|
|
202
230
|
class="item-actions__button item-actions__mark-read"
|
|
203
231
|
data-action="mark-read"
|
|
@@ -209,6 +237,7 @@
|
|
|
209
237
|
<span class="visually-hidden">Mark read</span>
|
|
210
238
|
</button>
|
|
211
239
|
{% endif %}
|
|
240
|
+
{% endif %}
|
|
212
241
|
{% if application.readlaterEndpoint %}
|
|
213
242
|
<button type="button"
|
|
214
243
|
class="item-actions__button item-actions__save-later"
|
package/views/timeline.njk
CHANGED
|
@@ -152,6 +152,88 @@
|
|
|
152
152
|
}
|
|
153
153
|
});
|
|
154
154
|
|
|
155
|
+
// Handle caret toggle for mark-source-read popover
|
|
156
|
+
timeline.addEventListener('click', (e) => {
|
|
157
|
+
const caret = e.target.closest('.item-actions__mark-read-caret');
|
|
158
|
+
if (!caret) return;
|
|
159
|
+
|
|
160
|
+
e.preventDefault();
|
|
161
|
+
e.stopPropagation();
|
|
162
|
+
|
|
163
|
+
// Close other open popovers
|
|
164
|
+
for (const p of timeline.querySelectorAll('.item-actions__mark-read-popover:not([hidden])')) {
|
|
165
|
+
if (p !== caret.nextElementSibling) p.hidden = true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const popover = caret.nextElementSibling;
|
|
169
|
+
if (popover) popover.hidden = !popover.hidden;
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Handle mark-source-read button
|
|
173
|
+
timeline.addEventListener('click', async (e) => {
|
|
174
|
+
const button = e.target.closest('.item-actions__mark-source-read');
|
|
175
|
+
if (!button) return;
|
|
176
|
+
|
|
177
|
+
e.preventDefault();
|
|
178
|
+
e.stopPropagation();
|
|
179
|
+
|
|
180
|
+
const feedId = button.dataset.feedId;
|
|
181
|
+
const channelUid = button.dataset.channelUid;
|
|
182
|
+
const channelId = button.dataset.channelId;
|
|
183
|
+
if (!feedId || (!channelUid && !channelId)) return;
|
|
184
|
+
|
|
185
|
+
button.disabled = true;
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
const formData = new URLSearchParams();
|
|
189
|
+
formData.append('action', 'timeline');
|
|
190
|
+
formData.append('method', 'mark_read_source');
|
|
191
|
+
formData.append('channel', channelUid || channelId);
|
|
192
|
+
formData.append('feed', feedId);
|
|
193
|
+
|
|
194
|
+
const response = await fetch(microsubApiUrl, {
|
|
195
|
+
method: 'POST',
|
|
196
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
197
|
+
body: formData.toString(),
|
|
198
|
+
credentials: 'same-origin'
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
if (response.ok) {
|
|
202
|
+
// Animate out all cards from this feed
|
|
203
|
+
const cards = timeline.querySelectorAll(`.item-card[data-feed-id="${feedId}"]`);
|
|
204
|
+
for (const card of cards) {
|
|
205
|
+
card.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
|
|
206
|
+
card.style.opacity = '0';
|
|
207
|
+
card.style.transform = 'translateX(-20px)';
|
|
208
|
+
}
|
|
209
|
+
setTimeout(() => {
|
|
210
|
+
for (const card of [...cards]) {
|
|
211
|
+
const wrapper = card.closest('.timeline-view__item');
|
|
212
|
+
if (wrapper) wrapper.remove();
|
|
213
|
+
else card.remove();
|
|
214
|
+
}
|
|
215
|
+
if (timeline.querySelectorAll('.item-card').length === 0) {
|
|
216
|
+
location.reload();
|
|
217
|
+
}
|
|
218
|
+
}, 300);
|
|
219
|
+
} else {
|
|
220
|
+
button.disabled = false;
|
|
221
|
+
}
|
|
222
|
+
} catch (error) {
|
|
223
|
+
console.error('Error marking source as read:', error);
|
|
224
|
+
button.disabled = false;
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// Close popovers on outside click
|
|
229
|
+
document.addEventListener('click', (e) => {
|
|
230
|
+
if (!e.target.closest('.item-actions__mark-read-group')) {
|
|
231
|
+
for (const p of timeline.querySelectorAll('.item-actions__mark-read-popover:not([hidden])')) {
|
|
232
|
+
p.hidden = true;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
155
237
|
// Handle save-for-later buttons
|
|
156
238
|
timeline.addEventListener('click', async (e) => {
|
|
157
239
|
const button = e.target.closest('.item-actions__save-later');
|