@rmdes/indiekit-endpoint-microsub 1.0.43 → 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/follow.js +18 -7
- package/lib/controllers/reader.js +67 -24
- package/lib/controllers/timeline.js +17 -0
- package/lib/feeds/fetcher.js +3 -0
- package/lib/polling/processor.js +4 -1
- package/lib/storage/feeds.js +80 -1
- package/lib/storage/items.js +36 -0
- package/package.json +1 -1
- package/views/channel.njk +78 -0
- package/views/feeds.njk +9 -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
|
}
|
|
@@ -67,13 +67,24 @@ export async function follow(request, response) {
|
|
|
67
67
|
throw new IndiekitError("Channel not found", { status: 404 });
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
// Create feed subscription
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
70
|
+
// Create feed subscription (throws DUPLICATE_FEED if already exists elsewhere)
|
|
71
|
+
let feed;
|
|
72
|
+
try {
|
|
73
|
+
feed = await createFeed(application, {
|
|
74
|
+
channelId: channelDocument._id,
|
|
75
|
+
url,
|
|
76
|
+
title: undefined, // Will be populated on first fetch
|
|
77
|
+
photo: undefined,
|
|
78
|
+
});
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (error.code === "DUPLICATE_FEED") {
|
|
81
|
+
throw new IndiekitError(
|
|
82
|
+
`Feed already exists in channel "${error.channelName}"`,
|
|
83
|
+
{ status: 409 },
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
77
88
|
|
|
78
89
|
// Trigger immediate fetch in background (don't await)
|
|
79
90
|
// This will also discover and subscribe to WebSub hubs
|
|
@@ -319,20 +319,43 @@ export async function addFeed(request, response) {
|
|
|
319
319
|
return response.status(404).render("404");
|
|
320
320
|
}
|
|
321
321
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
322
|
+
try {
|
|
323
|
+
// Create feed subscription (throws DUPLICATE_FEED if already exists)
|
|
324
|
+
const feed = await createFeed(application, {
|
|
325
|
+
channelId: channelDocument._id,
|
|
326
|
+
url,
|
|
327
|
+
title: undefined,
|
|
328
|
+
photo: undefined,
|
|
329
|
+
});
|
|
329
330
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
331
|
+
// Trigger immediate fetch in background
|
|
332
|
+
refreshFeedNow(application, feed._id).catch((error) => {
|
|
333
|
+
console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
|
|
334
|
+
});
|
|
334
335
|
|
|
335
|
-
|
|
336
|
+
response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
|
|
337
|
+
} catch (error) {
|
|
338
|
+
if (error.code === "DUPLICATE_FEED") {
|
|
339
|
+
// Re-render feeds page with error message
|
|
340
|
+
const feedList = await getFeedsForChannel(application, channelDocument._id);
|
|
341
|
+
return response.render("feeds", {
|
|
342
|
+
title: request.__("microsub.feeds.title"),
|
|
343
|
+
channel: channelDocument,
|
|
344
|
+
feeds: feedList,
|
|
345
|
+
baseUrl: request.baseUrl,
|
|
346
|
+
readerBaseUrl: request.baseUrl,
|
|
347
|
+
activeView: "channels",
|
|
348
|
+
error: `This feed already exists in channel "${error.channelName}"`,
|
|
349
|
+
breadcrumbs: [
|
|
350
|
+
{ text: "Reader", href: request.baseUrl },
|
|
351
|
+
{ text: "Channels", href: `${request.baseUrl}/channels` },
|
|
352
|
+
{ text: channelDocument.name, href: `${request.baseUrl}/channels/${uid}` },
|
|
353
|
+
{ text: "Feeds" },
|
|
354
|
+
],
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
throw error;
|
|
358
|
+
}
|
|
336
359
|
}
|
|
337
360
|
|
|
338
361
|
/**
|
|
@@ -782,20 +805,40 @@ export async function subscribe(request, response) {
|
|
|
782
805
|
}
|
|
783
806
|
}
|
|
784
807
|
|
|
785
|
-
// Create feed subscription
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
808
|
+
// Create feed subscription (throws DUPLICATE_FEED if already exists elsewhere)
|
|
809
|
+
try {
|
|
810
|
+
const feed = await createFeed(application, {
|
|
811
|
+
channelId: channelDocument._id,
|
|
812
|
+
url,
|
|
813
|
+
title: undefined,
|
|
814
|
+
photo: undefined,
|
|
815
|
+
});
|
|
792
816
|
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
817
|
+
// Trigger immediate fetch in background
|
|
818
|
+
refreshFeedNow(application, feed._id).catch((error) => {
|
|
819
|
+
console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
|
|
820
|
+
});
|
|
797
821
|
|
|
798
|
-
|
|
822
|
+
response.redirect(`${request.baseUrl}/channels/${channelUid}/feeds`);
|
|
823
|
+
} catch (error) {
|
|
824
|
+
if (error.code === "DUPLICATE_FEED") {
|
|
825
|
+
const channelList = await getChannels(application, userId);
|
|
826
|
+
return response.render("search", {
|
|
827
|
+
title: request.__("microsub.search.title"),
|
|
828
|
+
channels: channelList,
|
|
829
|
+
query: url,
|
|
830
|
+
validationError: `This feed already exists in channel "${error.channelName}"`,
|
|
831
|
+
baseUrl: request.baseUrl,
|
|
832
|
+
readerBaseUrl: request.baseUrl,
|
|
833
|
+
activeView: "channels",
|
|
834
|
+
breadcrumbs: [
|
|
835
|
+
{ text: "Reader", href: request.baseUrl },
|
|
836
|
+
{ text: "Search" },
|
|
837
|
+
],
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
throw error;
|
|
841
|
+
}
|
|
799
842
|
}
|
|
800
843
|
|
|
801
844
|
/**
|
|
@@ -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/feeds/fetcher.js
CHANGED
|
@@ -171,12 +171,14 @@ export async function fetchAndParseFeed(url, options = {}) {
|
|
|
171
171
|
// Fetch and parse the discovered feed
|
|
172
172
|
const feedResult = await fetchFeed(fallbackFeed.url, options);
|
|
173
173
|
if (!feedResult.notModified) {
|
|
174
|
+
const fallbackType = detectFeedType(feedResult.content, feedResult.contentType);
|
|
174
175
|
const parsed = await parseFeed(feedResult.content, fallbackFeed.url, {
|
|
175
176
|
contentType: feedResult.contentType,
|
|
176
177
|
});
|
|
177
178
|
return {
|
|
178
179
|
...feedResult,
|
|
179
180
|
...parsed,
|
|
181
|
+
feedType: fallbackType,
|
|
180
182
|
hub: feedResult.hub || parsed._hub,
|
|
181
183
|
discoveredFrom: url,
|
|
182
184
|
};
|
|
@@ -194,6 +196,7 @@ export async function fetchAndParseFeed(url, options = {}) {
|
|
|
194
196
|
return {
|
|
195
197
|
...result,
|
|
196
198
|
...parsed,
|
|
199
|
+
feedType: feedType,
|
|
197
200
|
hub: result.hub || parsed._hub,
|
|
198
201
|
};
|
|
199
202
|
}
|
package/lib/polling/processor.js
CHANGED
|
@@ -132,13 +132,16 @@ export async function processFeed(application, feed) {
|
|
|
132
132
|
lastModified: parsed.lastModified,
|
|
133
133
|
};
|
|
134
134
|
|
|
135
|
-
// Update feed title/photo if discovered
|
|
135
|
+
// Update feed title/photo/feedType if discovered
|
|
136
136
|
if (parsed.name && !feed.title) {
|
|
137
137
|
updateData.title = parsed.name;
|
|
138
138
|
}
|
|
139
139
|
if (parsed.photo && !feed.photo) {
|
|
140
140
|
updateData.photo = parsed.photo;
|
|
141
141
|
}
|
|
142
|
+
if (parsed.feedType && !feed.feedType) {
|
|
143
|
+
updateData.feedType = parsed.feedType;
|
|
144
|
+
}
|
|
142
145
|
|
|
143
146
|
await updateFeedAfterFetch(
|
|
144
147
|
application,
|
package/lib/storage/feeds.js
CHANGED
|
@@ -16,6 +16,73 @@ function getCollection(application) {
|
|
|
16
16
|
return application.collections.get("microsub_feeds");
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Normalize a feed URL for duplicate comparison.
|
|
21
|
+
* Strips trailing slashes, normalizes protocol to https, lowercases hostname.
|
|
22
|
+
* @param {string} url - Feed URL
|
|
23
|
+
* @returns {string} Normalized URL
|
|
24
|
+
*/
|
|
25
|
+
export function normalizeUrl(url) {
|
|
26
|
+
try {
|
|
27
|
+
const parsed = new URL(url);
|
|
28
|
+
// Normalize protocol to https
|
|
29
|
+
parsed.protocol = "https:";
|
|
30
|
+
// Lowercase hostname
|
|
31
|
+
parsed.hostname = parsed.hostname.toLowerCase();
|
|
32
|
+
// Remove trailing slash from path (but keep "/" for root)
|
|
33
|
+
if (parsed.pathname.length > 1 && parsed.pathname.endsWith("/")) {
|
|
34
|
+
parsed.pathname = parsed.pathname.slice(0, -1);
|
|
35
|
+
}
|
|
36
|
+
return parsed.href;
|
|
37
|
+
} catch {
|
|
38
|
+
return url;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Find an existing feed across ALL channels by normalized URL
|
|
44
|
+
* @param {object} application - Indiekit application
|
|
45
|
+
* @param {string} url - Feed URL to check
|
|
46
|
+
* @returns {Promise<object|null>} Existing feed with channel info, or null
|
|
47
|
+
*/
|
|
48
|
+
export async function findFeedAcrossChannels(application, url) {
|
|
49
|
+
const collection = getCollection(application);
|
|
50
|
+
const normalized = normalizeUrl(url);
|
|
51
|
+
|
|
52
|
+
// Get all feeds and check normalized URLs
|
|
53
|
+
// We check a few common URL variants directly for efficiency
|
|
54
|
+
const variants = new Set();
|
|
55
|
+
variants.add(url);
|
|
56
|
+
variants.add(normalized);
|
|
57
|
+
// Also try with/without trailing slash
|
|
58
|
+
if (url.endsWith("/")) {
|
|
59
|
+
variants.add(url.slice(0, -1));
|
|
60
|
+
} else {
|
|
61
|
+
variants.add(url + "/");
|
|
62
|
+
}
|
|
63
|
+
// Try http/https variants
|
|
64
|
+
if (url.startsWith("https://")) {
|
|
65
|
+
variants.add(url.replace("https://", "http://"));
|
|
66
|
+
} else if (url.startsWith("http://")) {
|
|
67
|
+
variants.add(url.replace("http://", "https://"));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const existing = await collection.findOne({
|
|
71
|
+
url: { $in: [...variants] },
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (!existing) return null;
|
|
75
|
+
|
|
76
|
+
// Look up the channel name for a useful error message
|
|
77
|
+
const channelsCollection = application.collections.get("microsub_channels");
|
|
78
|
+
const channel = await channelsCollection.findOne({ _id: existing.channelId });
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
feed: existing,
|
|
82
|
+
channelName: channel?.name || "unknown channel",
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
19
86
|
/**
|
|
20
87
|
* Create a new feed subscription
|
|
21
88
|
* @param {object} application - Indiekit application
|
|
@@ -32,12 +99,24 @@ export async function createFeed(
|
|
|
32
99
|
) {
|
|
33
100
|
const collection = getCollection(application);
|
|
34
101
|
|
|
35
|
-
// Check if feed already exists in channel
|
|
102
|
+
// Check if feed already exists in this channel (exact match)
|
|
36
103
|
const existing = await collection.findOne({ channelId, url });
|
|
37
104
|
if (existing) {
|
|
38
105
|
return existing;
|
|
39
106
|
}
|
|
40
107
|
|
|
108
|
+
// Check for duplicate across ALL channels (normalized URL)
|
|
109
|
+
const duplicate = await findFeedAcrossChannels(application, url);
|
|
110
|
+
if (duplicate) {
|
|
111
|
+
const error = new Error(
|
|
112
|
+
`Feed already exists in channel "${duplicate.channelName}"`,
|
|
113
|
+
);
|
|
114
|
+
error.code = "DUPLICATE_FEED";
|
|
115
|
+
error.existingFeed = duplicate.feed;
|
|
116
|
+
error.channelName = duplicate.channelName;
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
|
|
41
120
|
const feed = {
|
|
42
121
|
channelId,
|
|
43
122
|
url,
|
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');
|
package/views/feeds.njk
CHANGED
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
|
|
11
11
|
<h2>{{ __("microsub.feeds.title") }}</h2>
|
|
12
12
|
|
|
13
|
+
{% if error %}
|
|
14
|
+
<div class="notice notice--error" role="alert">
|
|
15
|
+
{{ error }}
|
|
16
|
+
</div>
|
|
17
|
+
{% endif %}
|
|
18
|
+
|
|
13
19
|
{% if feeds.length > 0 %}
|
|
14
20
|
<div class="feeds__list">
|
|
15
21
|
{% for feed in feeds %}
|
|
@@ -27,6 +33,9 @@
|
|
|
27
33
|
<div class="feeds__details">
|
|
28
34
|
<span class="feeds__name">
|
|
29
35
|
{{ feed.title or feed.url }}
|
|
36
|
+
{% if feed.feedType %}
|
|
37
|
+
<span class="badge badge--offset badge--small" title="Feed format">{{ feed.feedType | upper }}</span>
|
|
38
|
+
{% endif %}
|
|
30
39
|
{% if feed.status == 'error' %}
|
|
31
40
|
<span class="badge badge--red">Error</span>
|
|
32
41
|
{% elif feed.status == 'active' %}
|
|
@@ -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');
|