@rmdes/indiekit-endpoint-microsub 1.0.0-beta.6 → 1.0.0-beta.8

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/index.js CHANGED
@@ -78,9 +78,18 @@ export default class MicrosubEndpoint {
78
78
  "/channels/:uid/settings",
79
79
  readerController.updateSettings,
80
80
  );
81
+ readerRouter.get("/channels/:uid/feeds", readerController.feeds);
82
+ readerRouter.post("/channels/:uid/feeds", readerController.addFeed);
83
+ readerRouter.post(
84
+ "/channels/:uid/feeds/remove",
85
+ readerController.removeFeed,
86
+ );
81
87
  readerRouter.get("/item/:id", readerController.item);
82
88
  readerRouter.get("/compose", readerController.compose);
83
89
  readerRouter.post("/compose", readerController.submitCompose);
90
+ readerRouter.get("/search", readerController.searchPage);
91
+ readerRouter.post("/search", readerController.searchFeeds);
92
+ readerRouter.post("/subscribe", readerController.subscribe);
84
93
  router.use("/reader", readerRouter);
85
94
 
86
95
  return router;
@@ -3,12 +3,19 @@
3
3
  * @module controllers/reader
4
4
  */
5
5
 
6
+ import { discoverFeedsFromUrl } from "../feeds/fetcher.js";
7
+ import { refreshFeedNow } from "../polling/scheduler.js";
6
8
  import {
7
9
  getChannels,
8
10
  getChannel,
9
11
  createChannel,
10
12
  updateChannelSettings,
11
13
  } from "../storage/channels.js";
14
+ import {
15
+ getFeedsForChannel,
16
+ createFeed,
17
+ deleteFeed,
18
+ } from "../storage/feeds.js";
12
19
  import { getTimelineItems, getItemById } from "../storage/items.js";
13
20
  import {
14
21
  validateChannelName,
@@ -76,6 +83,7 @@ export async function createChannelAction(request, response) {
76
83
  * View channel timeline
77
84
  * @param {object} request - Express request
78
85
  * @param {object} response - Express response
86
+ * @returns {Promise<void>}
79
87
  */
80
88
  export async function channel(request, response) {
81
89
  const { application } = request.app.locals;
@@ -107,6 +115,7 @@ export async function channel(request, response) {
107
115
  * Channel settings form
108
116
  * @param {object} request - Express request
109
117
  * @param {object} response - Express response
118
+ * @returns {Promise<void>}
110
119
  */
111
120
  export async function settings(request, response) {
112
121
  const { application } = request.app.locals;
@@ -131,6 +140,7 @@ export async function settings(request, response) {
131
140
  * Update channel settings
132
141
  * @param {object} request - Express request
133
142
  * @param {object} response - Express response
143
+ * @returns {Promise<void>}
134
144
  */
135
145
  export async function updateSettings(request, response) {
136
146
  const { application } = request.app.locals;
@@ -161,10 +171,92 @@ export async function updateSettings(request, response) {
161
171
  response.redirect(`${request.baseUrl}/channels/${uid}`);
162
172
  }
163
173
 
174
+ /**
175
+ * View feeds for a channel
176
+ * @param {object} request - Express request
177
+ * @param {object} response - Express response
178
+ * @returns {Promise<void>}
179
+ */
180
+ export async function feeds(request, response) {
181
+ const { application } = request.app.locals;
182
+ const userId = request.session?.userId;
183
+ const { uid } = request.params;
184
+
185
+ const channelDocument = await getChannel(application, uid, userId);
186
+ if (!channelDocument) {
187
+ return response.status(404).render("404");
188
+ }
189
+
190
+ const feedList = await getFeedsForChannel(application, channelDocument._id);
191
+
192
+ response.render("feeds", {
193
+ title: request.__("microsub.feeds.title"),
194
+ channel: channelDocument,
195
+ feeds: feedList,
196
+ baseUrl: request.baseUrl,
197
+ });
198
+ }
199
+
200
+ /**
201
+ * Add feed to channel
202
+ * @param {object} request - Express request
203
+ * @param {object} response - Express response
204
+ * @returns {Promise<void>}
205
+ */
206
+ export async function addFeed(request, response) {
207
+ const { application } = request.app.locals;
208
+ const userId = request.session?.userId;
209
+ const { uid } = request.params;
210
+ const { url } = request.body;
211
+
212
+ const channelDocument = await getChannel(application, uid, userId);
213
+ if (!channelDocument) {
214
+ return response.status(404).render("404");
215
+ }
216
+
217
+ // Create feed subscription
218
+ const feed = await createFeed(application, {
219
+ channelId: channelDocument._id,
220
+ url,
221
+ title: undefined,
222
+ photo: undefined,
223
+ });
224
+
225
+ // Trigger immediate fetch in background
226
+ refreshFeedNow(application, feed._id).catch((error) => {
227
+ console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
228
+ });
229
+
230
+ response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
231
+ }
232
+
233
+ /**
234
+ * Remove feed from channel
235
+ * @param {object} request - Express request
236
+ * @param {object} response - Express response
237
+ * @returns {Promise<void>}
238
+ */
239
+ export async function removeFeed(request, response) {
240
+ const { application } = request.app.locals;
241
+ const userId = request.session?.userId;
242
+ const { uid } = request.params;
243
+ const { url } = request.body;
244
+
245
+ const channelDocument = await getChannel(application, uid, userId);
246
+ if (!channelDocument) {
247
+ return response.status(404).render("404");
248
+ }
249
+
250
+ await deleteFeed(application, channelDocument._id, url);
251
+
252
+ response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
253
+ }
254
+
164
255
  /**
165
256
  * View single item
166
257
  * @param {object} request - Express request
167
258
  * @param {object} response - Express response
259
+ * @returns {Promise<void>}
168
260
  */
169
261
  export async function item(request, response) {
170
262
  const { application } = request.app.locals;
@@ -187,6 +279,7 @@ export async function item(request, response) {
187
279
  * Compose response form
188
280
  * @param {object} request - Express request
189
281
  * @param {object} response - Express response
282
+ * @returns {Promise<void>}
190
283
  */
191
284
  export async function compose(request, response) {
192
285
  const { replyTo, likeOf, repostOf } = request.query;
@@ -210,6 +303,89 @@ export async function submitCompose(request, response) {
210
303
  response.redirect(`${request.baseUrl}/channels`);
211
304
  }
212
305
 
306
+ /**
307
+ * Search/discover feeds page
308
+ * @param {object} request - Express request
309
+ * @param {object} response - Express response
310
+ * @returns {Promise<void>}
311
+ */
312
+ export async function searchPage(request, response) {
313
+ const { application } = request.app.locals;
314
+ const userId = request.session?.userId;
315
+
316
+ const channelList = await getChannels(application, userId);
317
+
318
+ response.render("search", {
319
+ title: request.__("microsub.search.title"),
320
+ channels: channelList,
321
+ baseUrl: request.baseUrl,
322
+ });
323
+ }
324
+
325
+ /**
326
+ * Search for feeds from URL
327
+ * @param {object} request - Express request
328
+ * @param {object} response - Express response
329
+ * @returns {Promise<void>}
330
+ */
331
+ export async function searchFeeds(request, response) {
332
+ const { application } = request.app.locals;
333
+ const userId = request.session?.userId;
334
+ const { query } = request.body;
335
+
336
+ const channelList = await getChannels(application, userId);
337
+
338
+ let results = [];
339
+ if (query) {
340
+ try {
341
+ results = await discoverFeedsFromUrl(query);
342
+ } catch {
343
+ // Ignore discovery errors
344
+ }
345
+ }
346
+
347
+ response.render("search", {
348
+ title: request.__("microsub.search.title"),
349
+ channels: channelList,
350
+ query,
351
+ results,
352
+ searched: true,
353
+ baseUrl: request.baseUrl,
354
+ });
355
+ }
356
+
357
+ /**
358
+ * Subscribe to a feed from search results
359
+ * @param {object} request - Express request
360
+ * @param {object} response - Express response
361
+ * @returns {Promise<void>}
362
+ */
363
+ export async function subscribe(request, response) {
364
+ const { application } = request.app.locals;
365
+ const userId = request.session?.userId;
366
+ const { url, channel: channelUid } = request.body;
367
+
368
+ const channelDocument = await getChannel(application, channelUid, userId);
369
+ if (!channelDocument) {
370
+ return response.status(404).render("404");
371
+ }
372
+
373
+ // Create feed subscription
374
+ const feed = await createFeed(application, {
375
+ channelId: channelDocument._id,
376
+ url,
377
+ title: undefined,
378
+ photo: undefined,
379
+ });
380
+
381
+ // Trigger immediate fetch in background
382
+ refreshFeedNow(application, feed._id).catch((error) => {
383
+ console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
384
+ });
385
+
386
+ response.redirect(`${request.baseUrl}/channels/${channelUid}/feeds`);
387
+ }
388
+
213
389
  export const readerController = {
214
390
  index,
215
391
  channels,
@@ -218,7 +394,13 @@ export const readerController = {
218
394
  channel,
219
395
  settings,
220
396
  updateSettings,
397
+ feeds,
398
+ addFeed,
399
+ removeFeed,
221
400
  item,
222
401
  compose,
223
402
  submitCompose,
403
+ searchPage,
404
+ searchFeeds,
405
+ subscribe,
224
406
  };
package/locales/en.json CHANGED
@@ -28,7 +28,9 @@
28
28
  "title": "Feeds",
29
29
  "follow": "Follow",
30
30
  "unfollow": "Unfollow",
31
- "empty": "No feeds followed in this channel"
31
+ "empty": "No feeds followed in this channel",
32
+ "url": "Feed URL",
33
+ "urlPlaceholder": "https://example.com/feed.xml"
32
34
  },
33
35
  "item": {
34
36
  "reply": "Reply",
@@ -47,7 +49,7 @@
47
49
  "repostOf": "Reposting"
48
50
  },
49
51
  "settings": {
50
- "title": "%{channel} settings",
52
+ "title": "{{channel}} settings",
51
53
  "excludeTypes": "Exclude interaction types",
52
54
  "excludeTypesHelp": "Select types of posts to hide from this channel",
53
55
  "excludeRegex": "Exclude pattern",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-microsub",
3
- "version": "1.0.0-beta.6",
3
+ "version": "1.0.0-beta.8",
4
4
  "description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.",
5
5
  "keywords": [
6
6
  "indiekit",
package/views/channel.njk CHANGED
@@ -7,6 +7,9 @@
7
7
  {{ icon("previous") }} {{ __("microsub.channels.title") }}
8
8
  </a>
9
9
  <div class="channel__actions">
10
+ <a href="{{ baseUrl }}/channels/{{ channel.uid }}/feeds" class="button button--secondary button--small">
11
+ {{ icon("syndicate") }} {{ __("microsub.feeds.title") }}
12
+ </a>
10
13
  <a href="{{ baseUrl }}/channels/{{ channel.uid }}/settings" class="button button--secondary button--small">
11
14
  {{ icon("updatePost") }} {{ __("microsub.channels.settings") }}
12
15
  </a>
@@ -0,0 +1,58 @@
1
+ {% extends "document.njk" %}
2
+
3
+ {% block content %}
4
+ <div class="feeds">
5
+ <header class="feeds__header">
6
+ <a href="{{ baseUrl }}/channels/{{ channel.uid }}" class="back-link">
7
+ {{ icon("previous") }} {{ channel.name }}
8
+ </a>
9
+ </header>
10
+
11
+ <h2>{{ __("microsub.feeds.title") }}</h2>
12
+
13
+ {% if feeds.length > 0 %}
14
+ <ul class="feeds__list">
15
+ {% for feed in feeds %}
16
+ <li class="feeds__item">
17
+ <div class="feeds__info">
18
+ {% if feed.photo %}
19
+ <img src="{{ feed.photo }}" alt="" class="feeds__photo" width="32" height="32" loading="lazy">
20
+ {% endif %}
21
+ <div class="feeds__details">
22
+ <span class="feeds__name">{{ feed.title or feed.url }}</span>
23
+ <a href="{{ feed.url }}" class="feeds__url" target="_blank" rel="noopener">{{ feed.url }}</a>
24
+ </div>
25
+ </div>
26
+ <form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds/remove" class="feeds__actions">
27
+ <input type="hidden" name="url" value="{{ feed.url }}">
28
+ {{ button({
29
+ text: __("microsub.feeds.unfollow"),
30
+ classes: "button--secondary button--small"
31
+ }) }}
32
+ </form>
33
+ </li>
34
+ {% endfor %}
35
+ </ul>
36
+ {% else %}
37
+ {{ prose({ text: __("microsub.feeds.empty") }) }}
38
+ {% endif %}
39
+
40
+ <div class="feeds__add">
41
+ <h3>{{ __("microsub.feeds.follow") }}</h3>
42
+ <form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds" class="feeds__form">
43
+ {{ input({
44
+ id: "url",
45
+ name: "url",
46
+ label: __("microsub.feeds.url"),
47
+ type: "url",
48
+ required: true,
49
+ placeholder: __("microsub.feeds.urlPlaceholder"),
50
+ autocomplete: "off"
51
+ }) }}
52
+ <div class="button-group">
53
+ {{ button({ text: __("microsub.feeds.follow") }) }}
54
+ </div>
55
+ </form>
56
+ </div>
57
+ </div>
58
+ {% endblock %}
package/views/reader.njk CHANGED
@@ -21,6 +21,9 @@
21
21
  {% endfor %}
22
22
  </ul>
23
23
  <p class="reader__actions">
24
+ <a href="{{ baseUrl }}/search" class="button button--primary">
25
+ {{ icon("syndicate") }} {{ __("microsub.feeds.follow") }}
26
+ </a>
24
27
  <a href="{{ baseUrl }}/channels/new" class="button button--secondary">
25
28
  {{ icon("createPost") }} {{ __("microsub.channels.new") }}
26
29
  </a>
@@ -0,0 +1,58 @@
1
+ {% extends "document.njk" %}
2
+
3
+ {% block content %}
4
+ <div class="search">
5
+ <a href="{{ baseUrl }}/channels" class="back-link">
6
+ {{ icon("previous") }} {{ __("microsub.channels.title") }}
7
+ </a>
8
+
9
+ <h2>{{ __("microsub.search.title") }}</h2>
10
+
11
+ <form method="post" action="{{ baseUrl }}/search" class="search__form">
12
+ {{ input({
13
+ id: "query",
14
+ name: "query",
15
+ label: __("microsub.search.placeholder"),
16
+ type: "url",
17
+ required: true,
18
+ placeholder: "https://example.com",
19
+ autocomplete: "off",
20
+ value: query,
21
+ attributes: { autofocus: true }
22
+ }) }}
23
+ <div class="button-group">
24
+ {{ button({ text: __("microsub.search.submit") }) }}
25
+ </div>
26
+ </form>
27
+
28
+ {% if results and results.length > 0 %}
29
+ <div class="search__results">
30
+ <h3>{{ __("microsub.search.title") }}</h3>
31
+ <ul class="search__list">
32
+ {% for result in results %}
33
+ <li class="search__item">
34
+ <div class="search__feed">
35
+ <span class="search__url">{{ result.url }}</span>
36
+ </div>
37
+ <form method="post" action="{{ baseUrl }}/subscribe" class="search__subscribe">
38
+ <input type="hidden" name="url" value="{{ result.url }}">
39
+ <label for="channel-{{ loop.index }}" class="visually-hidden">{{ __("microsub.channels.title") }}</label>
40
+ <select name="channel" id="channel-{{ loop.index }}" class="select select--small">
41
+ {% for channel in channels %}
42
+ <option value="{{ channel.uid }}">{{ channel.name }}</option>
43
+ {% endfor %}
44
+ </select>
45
+ {{ button({
46
+ text: __("microsub.feeds.follow"),
47
+ classes: "button--small"
48
+ }) }}
49
+ </form>
50
+ </li>
51
+ {% endfor %}
52
+ </ul>
53
+ </div>
54
+ {% elif searched %}
55
+ {{ prose({ text: __("microsub.search.noResults") }) }}
56
+ {% endif %}
57
+ </div>
58
+ {% endblock %}