@rmdes/indiekit-endpoint-microsub 1.0.0-beta.1 → 1.0.0-beta.10

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;
@@ -108,6 +117,8 @@ export default class MicrosubEndpoint {
108
117
  * @param {object} indiekit - Indiekit instance
109
118
  */
110
119
  init(indiekit) {
120
+ console.info("[Microsub] Initializing endpoint-microsub plugin");
121
+
111
122
  // Register MongoDB collections
112
123
  indiekit.addCollection("microsub_channels");
113
124
  indiekit.addCollection("microsub_feeds");
@@ -116,6 +127,8 @@ export default class MicrosubEndpoint {
116
127
  indiekit.addCollection("microsub_muted");
117
128
  indiekit.addCollection("microsub_blocked");
118
129
 
130
+ console.info("[Microsub] Registered MongoDB collections");
131
+
119
132
  // Register endpoint
120
133
  indiekit.addEndpoint(this);
121
134
 
@@ -127,7 +140,12 @@ export default class MicrosubEndpoint {
127
140
  // Start feed polling scheduler when server starts
128
141
  // This will be called after the server is ready
129
142
  if (indiekit.database) {
143
+ console.info("[Microsub] Database available, starting scheduler");
130
144
  startScheduler(indiekit);
145
+ } else {
146
+ console.warn(
147
+ "[Microsub] Database not available at init, scheduler not started",
148
+ );
131
149
  }
132
150
  }
133
151
 
@@ -3,22 +3,56 @@
3
3
  * @module cache/redis
4
4
  */
5
5
 
6
+ import Redis from "ioredis";
7
+
8
+ let redisClient;
9
+
6
10
  /**
7
11
  * Get Redis client from application
8
12
  * @param {object} application - Indiekit application
9
13
  * @returns {object|undefined} Redis client or undefined
10
14
  */
11
15
  export function getRedisClient(application) {
12
- // Check if Redis is configured
16
+ // Check if Redis is already initialized on the application
13
17
  if (application.redis) {
14
18
  return application.redis;
15
19
  }
16
20
 
21
+ // Check if we already created a client
22
+ if (redisClient) {
23
+ return redisClient;
24
+ }
25
+
17
26
  // Check for Redis URL in config
18
- if (application.config?.application?.redisUrl) {
19
- // Lazily create Redis connection
20
- // This will be implemented when Redis support is added to Indiekit core
21
- return;
27
+ const redisUrl = application.config?.application?.redisUrl;
28
+ if (redisUrl) {
29
+ try {
30
+ redisClient = new Redis(redisUrl, {
31
+ maxRetriesPerRequest: 3,
32
+ retryStrategy(times) {
33
+ const delay = Math.min(times * 50, 2000);
34
+ return delay;
35
+ },
36
+ lazyConnect: true,
37
+ });
38
+
39
+ redisClient.on("error", (error) => {
40
+ console.error("[Microsub] Redis error:", error.message);
41
+ });
42
+
43
+ redisClient.on("connect", () => {
44
+ console.info("[Microsub] Redis connected");
45
+ });
46
+
47
+ // Connect asynchronously
48
+ redisClient.connect().catch((error) => {
49
+ console.warn("[Microsub] Redis connection failed:", error.message);
50
+ });
51
+
52
+ return redisClient;
53
+ } catch (error) {
54
+ console.warn("[Microsub] Failed to initialize Redis:", error.message);
55
+ }
22
56
  }
23
57
  }
24
58
 
@@ -131,3 +165,17 @@ export async function subscribeToChannel(redis, channel, callback) {
131
165
  // Ignore subscription errors
132
166
  }
133
167
  }
168
+
169
+ /**
170
+ * Cleanup Redis connection on shutdown
171
+ */
172
+ export async function closeRedis() {
173
+ if (redisClient) {
174
+ try {
175
+ await redisClient.quit();
176
+ redisClient = undefined;
177
+ } catch {
178
+ // Ignore cleanup errors
179
+ }
180
+ }
181
+ }
@@ -25,6 +25,12 @@ import { get as getTimeline, action as timelineAction } from "./timeline.js";
25
25
  export async function get(request, response, next) {
26
26
  try {
27
27
  const { action } = request.query;
28
+
29
+ // If no action provided, redirect to reader UI
30
+ if (!action) {
31
+ return response.redirect(request.baseUrl + "/reader");
32
+ }
33
+
28
34
  validateAction(action);
29
35
 
30
36
  switch (action) {
@@ -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,
@@ -39,6 +46,7 @@ export async function channels(request, response) {
39
46
  response.render("reader", {
40
47
  title: request.__("microsub.reader.title"),
41
48
  channels: channelList,
49
+ baseUrl: request.baseUrl,
42
50
  });
43
51
  }
44
52
 
@@ -50,6 +58,7 @@ export async function channels(request, response) {
50
58
  export async function newChannel(request, response) {
51
59
  response.render("channel-new", {
52
60
  title: request.__("microsub.channels.new"),
61
+ baseUrl: request.baseUrl,
53
62
  });
54
63
  }
55
64
 
@@ -74,6 +83,7 @@ export async function createChannelAction(request, response) {
74
83
  * View channel timeline
75
84
  * @param {object} request - Express request
76
85
  * @param {object} response - Express response
86
+ * @returns {Promise<void>}
77
87
  */
78
88
  export async function channel(request, response) {
79
89
  const { application } = request.app.locals;
@@ -97,6 +107,7 @@ export async function channel(request, response) {
97
107
  channel: channelDocument,
98
108
  items: timeline.items,
99
109
  paging: timeline.paging,
110
+ baseUrl: request.baseUrl,
100
111
  });
101
112
  }
102
113
 
@@ -104,6 +115,7 @@ export async function channel(request, response) {
104
115
  * Channel settings form
105
116
  * @param {object} request - Express request
106
117
  * @param {object} response - Express response
118
+ * @returns {Promise<void>}
107
119
  */
108
120
  export async function settings(request, response) {
109
121
  const { application } = request.app.locals;
@@ -120,6 +132,7 @@ export async function settings(request, response) {
120
132
  channel: channelDocument.name,
121
133
  }),
122
134
  channel: channelDocument,
135
+ baseUrl: request.baseUrl,
123
136
  });
124
137
  }
125
138
 
@@ -127,6 +140,7 @@ export async function settings(request, response) {
127
140
  * Update channel settings
128
141
  * @param {object} request - Express request
129
142
  * @param {object} response - Express response
143
+ * @returns {Promise<void>}
130
144
  */
131
145
  export async function updateSettings(request, response) {
132
146
  const { application } = request.app.locals;
@@ -157,10 +171,92 @@ export async function updateSettings(request, response) {
157
171
  response.redirect(`${request.baseUrl}/channels/${uid}`);
158
172
  }
159
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
+
160
255
  /**
161
256
  * View single item
162
257
  * @param {object} request - Express request
163
258
  * @param {object} response - Express response
259
+ * @returns {Promise<void>}
164
260
  */
165
261
  export async function item(request, response) {
166
262
  const { application } = request.app.locals;
@@ -175,6 +271,7 @@ export async function item(request, response) {
175
271
  response.render("item", {
176
272
  title: itemDocument.name || "Item",
177
273
  item: itemDocument,
274
+ baseUrl: request.baseUrl,
178
275
  });
179
276
  }
180
277
 
@@ -182,6 +279,7 @@ export async function item(request, response) {
182
279
  * Compose response form
183
280
  * @param {object} request - Express request
184
281
  * @param {object} response - Express response
282
+ * @returns {Promise<void>}
185
283
  */
186
284
  export async function compose(request, response) {
187
285
  const { replyTo, likeOf, repostOf } = request.query;
@@ -191,6 +289,7 @@ export async function compose(request, response) {
191
289
  replyTo,
192
290
  likeOf,
193
291
  repostOf,
292
+ baseUrl: request.baseUrl,
194
293
  });
195
294
  }
196
295
 
@@ -204,6 +303,89 @@ export async function submitCompose(request, response) {
204
303
  response.redirect(`${request.baseUrl}/channels`);
205
304
  }
206
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
+
207
389
  export const readerController = {
208
390
  index,
209
391
  channels,
@@ -212,7 +394,13 @@ export const readerController = {
212
394
  channel,
213
395
  settings,
214
396
  updateSettings,
397
+ feeds,
398
+ addFeed,
399
+ removeFeed,
215
400
  item,
216
401
  compose,
217
402
  submitCompose,
403
+ searchPage,
404
+ searchFeeds,
405
+ subscribe,
218
406
  };
@@ -94,7 +94,8 @@ export async function searchItemsRegex(
94
94
  { "author.name": regex },
95
95
  ],
96
96
  })
97
- .toSorted({ published: -1 })
97
+ // eslint-disable-next-line unicorn/no-array-sort -- MongoDB cursor method, not Array#sort
98
+ .sort({ published: -1 })
98
99
  .limit(limit)
99
100
  .toArray();
100
101
 
@@ -55,7 +55,8 @@ export async function createChannel(application, { name, userId }) {
55
55
  // Get max order for user
56
56
  const maxOrderResult = await collection
57
57
  .find({ userId })
58
- .toSorted({ order: -1 })
58
+ // eslint-disable-next-line unicorn/no-array-sort -- MongoDB cursor method, not Array#sort
59
+ .sort({ order: -1 })
59
60
  .limit(1)
60
61
  .toArray();
61
62
 
@@ -93,7 +94,8 @@ export async function getChannels(application, userId) {
93
94
  const channels = await collection
94
95
  // eslint-disable-next-line unicorn/no-array-callback-reference -- filter is MongoDB query object
95
96
  .find(filter)
96
- .toSorted({ order: 1 })
97
+ // eslint-disable-next-line unicorn/no-array-sort -- MongoDB cursor method, not Array#sort
98
+ .sort({ order: 1 })
97
99
  .toArray();
98
100
 
99
101
  // Get unread counts for each channel
@@ -99,7 +99,8 @@ export async function getTimelineItems(application, channelId, options = {}) {
99
99
  const items = await collection
100
100
  // eslint-disable-next-line unicorn/no-array-callback-reference -- query is MongoDB query object
101
101
  .find(query)
102
- .toSorted(sort)
102
+ // eslint-disable-next-line unicorn/no-array-sort -- MongoDB cursor method, not Array#sort
103
+ .sort(sort)
103
104
  .limit(limit + 1)
104
105
  .toArray();
105
106
 
@@ -370,7 +371,8 @@ export async function searchItems(application, channelId, query, limit = 20) {
370
371
  { summary: regex },
371
372
  ],
372
373
  })
373
- .toSorted({ published: -1 })
374
+ // eslint-disable-next-line unicorn/no-array-sort -- MongoDB cursor method, not Array#sort
375
+ .sort({ published: -1 })
374
376
  .limit(limit)
375
377
  .toArray();
376
378
 
@@ -127,13 +127,13 @@ export async function getNotifications(application, userId, options = {}) {
127
127
  query.readBy = { $ne: userId };
128
128
  }
129
129
 
130
- /* eslint-disable unicorn/no-array-callback-reference -- query is MongoDB query object */
130
+ /* eslint-disable unicorn/no-array-callback-reference, unicorn/no-array-sort -- MongoDB cursor methods */
131
131
  const notifications = await collection
132
132
  .find(query)
133
- .toSorted({ published: -1 })
133
+ .sort({ published: -1 })
134
134
  .limit(limit)
135
135
  .toArray();
136
- /* eslint-enable unicorn/no-array-callback-reference */
136
+ /* eslint-enable unicorn/no-array-callback-reference, unicorn/no-array-sort */
137
137
 
138
138
  return notifications.map((n) => transformNotification(n, userId));
139
139
  }
package/locales/en.json CHANGED
@@ -9,6 +9,7 @@
9
9
  },
10
10
  "channels": {
11
11
  "title": "Channels",
12
+ "name": "Channel name",
12
13
  "new": "New channel",
13
14
  "create": "Create channel",
14
15
  "delete": "Delete channel",
@@ -27,7 +28,9 @@
27
28
  "title": "Feeds",
28
29
  "follow": "Follow",
29
30
  "unfollow": "Unfollow",
30
- "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"
31
34
  },
32
35
  "item": {
33
36
  "reply": "Reply",
@@ -46,7 +49,7 @@
46
49
  "repostOf": "Reposting"
47
50
  },
48
51
  "settings": {
49
- "title": "%{channel} settings",
52
+ "title": "{{channel}} settings",
50
53
  "excludeTypes": "Exclude interaction types",
51
54
  "excludeTypesHelp": "Select types of posts to hide from this channel",
52
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.1",
3
+ "version": "1.0.0-beta.10",
4
4
  "description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.",
5
5
  "keywords": [
6
6
  "indiekit",
@@ -2,29 +2,25 @@
2
2
 
3
3
  {% block content %}
4
4
  <div class="channel-new">
5
- <a href="{{ request.baseUrl }}/channels" class="back-link">
6
- {{ icon("arrow-left") }} {{ __("microsub.channels.title") }}
5
+ <a href="{{ baseUrl }}/channels" class="back-link">
6
+ {{ icon("previous") }} {{ __("microsub.channels.title") }}
7
7
  </a>
8
8
 
9
- <form method="post" action="{{ request.baseUrl }}/channels/new">
10
- {{ field({
11
- label: {
12
- text: __("microsub.channels.new")
13
- },
14
- input: {
15
- id: "name",
16
- name: "name",
17
- required: true,
18
- autocomplete: "off",
19
- autofocus: true
20
- }
9
+ <form method="post" action="{{ baseUrl }}/channels/new">
10
+ {{ input({
11
+ id: "name",
12
+ name: "name",
13
+ label: __("microsub.channels.name"),
14
+ required: true,
15
+ autocomplete: "off",
16
+ attributes: { autofocus: true }
21
17
  }) }}
22
18
 
23
19
  <div class="button-group">
24
20
  {{ button({
25
21
  text: __("microsub.channels.create")
26
22
  }) }}
27
- <a href="{{ request.baseUrl }}/channels" class="button button--secondary">
23
+ <a href="{{ baseUrl }}/channels" class="button button--secondary">
28
24
  {{ __("Cancel") }}
29
25
  </a>
30
26
  </div>
package/views/channel.njk CHANGED
@@ -3,12 +3,15 @@
3
3
  {% block content %}
4
4
  <div class="channel">
5
5
  <header class="channel__header">
6
- <a href="{{ request.baseUrl }}/channels" class="back-link">
7
- {{ icon("arrow-left") }} {{ __("microsub.channels.title") }}
6
+ <a href="{{ baseUrl }}/channels" class="back-link">
7
+ {{ icon("previous") }} {{ __("microsub.channels.title") }}
8
8
  </a>
9
9
  <div class="channel__actions">
10
- <a href="{{ request.baseUrl }}/channels/{{ channel.uid }}/settings" class="button button--secondary button--small">
11
- {{ icon("settings") }} {{ __("microsub.channels.settings") }}
10
+ <a href="{{ baseUrl }}/channels/{{ channel.uid }}/feeds" class="button button--secondary button--small">
11
+ {{ icon("syndicate") }} {{ __("microsub.feeds.title") }}
12
+ </a>
13
+ <a href="{{ baseUrl }}/channels/{{ channel.uid }}/settings" class="button button--secondary button--small">
14
+ {{ icon("updatePost") }} {{ __("microsub.channels.settings") }}
12
15
  </a>
13
16
  </div>
14
17
  </header>
@@ -24,12 +27,12 @@
24
27
  <nav class="timeline__paging" aria-label="Pagination">
25
28
  {% if paging.before %}
26
29
  <a href="?before={{ paging.before }}" class="button button--secondary">
27
- {{ icon("arrow-left") }} {{ __("microsub.reader.newer") }}
30
+ {{ icon("previous") }} {{ __("microsub.reader.newer") }}
28
31
  </a>
29
32
  {% endif %}
30
33
  {% if paging.after %}
31
34
  <a href="?after={{ paging.after }}" class="button button--secondary">
32
- {{ __("microsub.reader.older") }} {{ icon("arrow-right") }}
35
+ {{ __("microsub.reader.older") }} {{ icon("next") }}
33
36
  </a>
34
37
  {% endif %}
35
38
  </nav>
package/views/compose.njk CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  {% block content %}
4
4
  <div class="compose">
5
- <a href="{{ request.headers.referer or request.baseUrl + '/channels' }}" class="back-link">
6
- {{ icon("arrow-left") }} {{ __("Back") }}
5
+ <a href="{{ baseUrl }}/channels" class="back-link">
6
+ {{ icon("previous") }} {{ __("Back") }}
7
7
  </a>
8
8
 
9
9
  {% if replyTo %}
@@ -14,7 +14,7 @@
14
14
 
15
15
  {% if likeOf %}
16
16
  <div class="compose__context">
17
- {{ icon("heart") }} {{ __("microsub.compose.likeOf") }}: <a href="{{ likeOf }}">{{ likeOf }}</a>
17
+ {{ icon("like") }} {{ __("microsub.compose.likeOf") }}: <a href="{{ likeOf }}">{{ likeOf }}</a>
18
18
  </div>
19
19
  {% endif %}
20
20
 
@@ -24,7 +24,7 @@
24
24
  </div>
25
25
  {% endif %}
26
26
 
27
- <form method="post" action="{{ request.baseUrl }}/compose">
27
+ <form method="post" action="{{ baseUrl }}/compose">
28
28
  {% if replyTo %}
29
29
  <input type="hidden" name="in-reply-to" value="{{ replyTo }}">
30
30
  {% endif %}
@@ -37,14 +37,11 @@
37
37
 
38
38
  {% if not likeOf and not repostOf %}
39
39
  {{ textarea({
40
- label: {
41
- text: __("microsub.compose.content"),
42
- classes: "label--visuallyhidden"
43
- },
40
+ label: __("microsub.compose.content"),
44
41
  id: "content",
45
42
  name: "content",
46
43
  rows: 5,
47
- autofocus: true
44
+ attributes: { autofocus: true }
48
45
  }) }}
49
46
  {% endif %}
50
47
 
@@ -52,7 +49,7 @@
52
49
  {{ button({
53
50
  text: __("microsub.compose.submit")
54
51
  }) }}
55
- <a href="{{ request.headers.referer or request.baseUrl + '/channels' }}" class="button button--secondary">
52
+ <a href="{{ baseUrl }}/channels" class="button button--secondary">
56
53
  {{ __("microsub.compose.cancel") }}
57
54
  </a>
58
55
  </div>
@@ -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/item.njk CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  {% block content %}
4
4
  <article class="item">
5
- <a href="{{ request.headers.referer or request.baseUrl + '/channels' }}" class="back-link">
6
- {{ icon("arrow-left") }} {{ __("Back") }}
5
+ <a href="{{ baseUrl }}/channels" class="back-link">
6
+ {{ icon("previous") }} {{ __("Back") }}
7
7
  </a>
8
8
 
9
9
  {% if item.author %}
@@ -56,7 +56,7 @@
56
56
  <p>{{ icon("reply") }} {{ __("Reply to") }}: <a href="{{ item.inReplyTo[0] }}">{{ item.inReplyTo[0] }}</a></p>
57
57
  {% endif %}
58
58
  {% if item.likeOf %}
59
- <p>{{ icon("heart") }} {{ __("Liked") }}: <a href="{{ item.likeOf[0] }}">{{ item.likeOf[0] }}</a></p>
59
+ <p>{{ icon("like") }} {{ __("Liked") }}: <a href="{{ item.likeOf[0] }}">{{ item.likeOf[0] }}</a></p>
60
60
  {% endif %}
61
61
  {% if item.repostOf %}
62
62
  <p>{{ icon("repost") }} {{ __("Reposted") }}: <a href="{{ item.repostOf[0] }}">{{ item.repostOf[0] }}</a></p>
@@ -68,17 +68,17 @@
68
68
  {% endif %}
69
69
 
70
70
  <footer class="item__actions">
71
- <a href="{{ request.baseUrl }}/compose?replyTo={{ item.url | urlencode }}" class="button button--secondary button--small">
71
+ <a href="{{ baseUrl }}/compose?replyTo={{ item.url | urlencode }}" class="button button--secondary button--small">
72
72
  {{ icon("reply") }} {{ __("microsub.item.reply") }}
73
73
  </a>
74
- <a href="{{ request.baseUrl }}/compose?likeOf={{ item.url | urlencode }}" class="button button--secondary button--small">
75
- {{ icon("heart") }} {{ __("microsub.item.like") }}
74
+ <a href="{{ baseUrl }}/compose?likeOf={{ item.url | urlencode }}" class="button button--secondary button--small">
75
+ {{ icon("like") }} {{ __("microsub.item.like") }}
76
76
  </a>
77
- <a href="{{ request.baseUrl }}/compose?repostOf={{ item.url | urlencode }}" class="button button--secondary button--small">
77
+ <a href="{{ baseUrl }}/compose?repostOf={{ item.url | urlencode }}" class="button button--secondary button--small">
78
78
  {{ icon("repost") }} {{ __("microsub.item.repost") }}
79
79
  </a>
80
80
  <a href="{{ item.url }}" class="button button--secondary button--small" target="_blank" rel="noopener">
81
- {{ icon("external") }} {{ __("microsub.item.viewOriginal") }}
81
+ {{ icon("public") }} {{ __("microsub.item.viewOriginal") }}
82
82
  </a>
83
83
  </footer>
84
84
  </article>
@@ -1,15 +1,15 @@
1
1
  {# Item action buttons #}
2
2
  <div class="item-actions">
3
- <a href="{{ request.baseUrl }}/compose?replyTo={{ itemUrl | urlencode }}" class="item-actions__button" title="{{ __('microsub.item.reply') }}">
3
+ <a href="{{ baseUrl }}/compose?replyTo={{ itemUrl | urlencode }}" class="item-actions__button" title="{{ __('microsub.item.reply') }}">
4
4
  {{ icon("reply") }}
5
5
  </a>
6
- <a href="{{ request.baseUrl }}/compose?likeOf={{ itemUrl | urlencode }}" class="item-actions__button" title="{{ __('microsub.item.like') }}">
7
- {{ icon("heart") }}
6
+ <a href="{{ baseUrl }}/compose?likeOf={{ itemUrl | urlencode }}" class="item-actions__button" title="{{ __('microsub.item.like') }}">
7
+ {{ icon("like") }}
8
8
  </a>
9
- <a href="{{ request.baseUrl }}/compose?repostOf={{ itemUrl | urlencode }}" class="item-actions__button" title="{{ __('microsub.item.repost') }}">
9
+ <a href="{{ baseUrl }}/compose?repostOf={{ itemUrl | urlencode }}" class="item-actions__button" title="{{ __('microsub.item.repost') }}">
10
10
  {{ icon("repost") }}
11
11
  </a>
12
12
  <a href="{{ itemUrl }}" class="item-actions__button" target="_blank" rel="noopener" title="{{ __('microsub.item.viewOriginal') }}">
13
- {{ icon("external") }}
13
+ {{ icon("public") }}
14
14
  </a>
15
15
  </div>
@@ -1,6 +1,6 @@
1
1
  {# Item card for timeline display #}
2
2
  <article class="item-card{% if item._is_read %} item-card--read{% endif %}">
3
- <a href="{{ request.baseUrl }}/item/{{ item.uid }}" class="item-card__link">
3
+ <a href="{{ baseUrl }}/item/{{ item.uid }}" class="item-card__link">
4
4
  {% if item.author %}
5
5
  <div class="item-card__author">
6
6
  {% if item.author.photo %}
@@ -18,7 +18,7 @@
18
18
  {% if item._type and item._type !== "entry" %}
19
19
  <div class="item-card__type">
20
20
  {% if item._type === "like" %}
21
- {{ icon("heart") }} Liked
21
+ {{ icon("like") }} Liked
22
22
  {% elif item._type === "repost" %}
23
23
  {{ icon("repost") }} Reposted
24
24
  {% elif item._type === "reply" %}
@@ -58,7 +58,7 @@
58
58
  </time>
59
59
  {% endif %}
60
60
  {% if not item._is_read %}
61
- <span class="item-card__unread">{{ icon("dot") }}</span>
61
+ <span class="item-card__unread" aria-label="Unread">●</span>
62
62
  {% endif %}
63
63
  </footer>
64
64
  </a>
package/views/reader.njk CHANGED
@@ -6,10 +6,10 @@
6
6
  <ul class="reader__channels">
7
7
  {% for channel in channels %}
8
8
  <li class="reader__channel">
9
- <a href="{{ request.baseUrl }}/channels/{{ channel.uid }}" class="reader__channel-link">
9
+ <a href="{{ baseUrl }}/channels/{{ channel.uid }}" class="reader__channel-link">
10
10
  <span class="reader__channel-name">
11
11
  {% if channel.uid === "notifications" %}
12
- {{ icon("bell") }}
12
+ {{ icon("mention") }}
13
13
  {% endif %}
14
14
  {{ channel.name }}
15
15
  </span>
@@ -21,14 +21,17 @@
21
21
  {% endfor %}
22
22
  </ul>
23
23
  <p class="reader__actions">
24
- <a href="{{ request.baseUrl }}/channels/new" class="button button--secondary">
25
- {{ icon("plus") }} {{ __("microsub.channels.new") }}
24
+ <a href="{{ baseUrl }}/search" class="button button--primary">
25
+ {{ icon("syndicate") }} {{ __("microsub.feeds.follow") }}
26
+ </a>
27
+ <a href="{{ baseUrl }}/channels/new" class="button button--secondary">
28
+ {{ icon("createPost") }} {{ __("microsub.channels.new") }}
26
29
  </a>
27
30
  </p>
28
31
  {% else %}
29
32
  {{ prose({ text: __("microsub.channels.empty") }) }}
30
33
  <p>
31
- <a href="{{ request.baseUrl }}/channels/new" class="button button--primary">
34
+ <a href="{{ baseUrl }}/channels/new" class="button button--primary">
32
35
  {{ __("microsub.channels.new") }}
33
36
  </a>
34
37
  </p>
@@ -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 %}
@@ -2,77 +2,55 @@
2
2
 
3
3
  {% block content %}
4
4
  <div class="settings">
5
- <a href="{{ request.baseUrl }}/channels/{{ channel.uid }}" class="back-link">
6
- {{ icon("arrow-left") }} {{ channel.name }}
5
+ <a href="{{ baseUrl }}/channels/{{ channel.uid }}" class="back-link">
6
+ {{ icon("previous") }} {{ channel.name }}
7
7
  </a>
8
8
 
9
- <form method="post" action="{{ request.baseUrl }}/channels/{{ channel.uid }}/settings">
10
- {{ fieldset({
11
- legend: {
12
- text: __("microsub.settings.excludeTypes"),
13
- classes: "fieldset__legend--medium"
14
- },
15
- hint: {
16
- text: __("microsub.settings.excludeTypesHelp")
9
+ <form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/settings">
10
+ {{ checkboxes({
11
+ name: "excludeTypes",
12
+ values: channel.settings.excludeTypes,
13
+ fieldset: {
14
+ legend: __("microsub.settings.excludeTypes")
17
15
  },
16
+ hint: __("microsub.settings.excludeTypesHelp"),
18
17
  items: [
19
18
  {
20
- id: "excludeTypes-like",
21
- name: "excludeTypes",
22
- value: "like",
23
- text: __("microsub.settings.types.like"),
24
- checked: channel.settings.excludeTypes | includes("like")
19
+ label: __("microsub.settings.types.like"),
20
+ value: "like"
25
21
  },
26
22
  {
27
- id: "excludeTypes-repost",
28
- name: "excludeTypes",
29
- value: "repost",
30
- text: __("microsub.settings.types.repost"),
31
- checked: channel.settings.excludeTypes | includes("repost")
23
+ label: __("microsub.settings.types.repost"),
24
+ value: "repost"
32
25
  },
33
26
  {
34
- id: "excludeTypes-bookmark",
35
- name: "excludeTypes",
36
- value: "bookmark",
37
- text: __("microsub.settings.types.bookmark"),
38
- checked: channel.settings.excludeTypes | includes("bookmark")
27
+ label: __("microsub.settings.types.bookmark"),
28
+ value: "bookmark"
39
29
  },
40
30
  {
41
- id: "excludeTypes-reply",
42
- name: "excludeTypes",
43
- value: "reply",
44
- text: __("microsub.settings.types.reply"),
45
- checked: channel.settings.excludeTypes | includes("reply")
31
+ label: __("microsub.settings.types.reply"),
32
+ value: "reply"
46
33
  },
47
34
  {
48
- id: "excludeTypes-checkin",
49
- name: "excludeTypes",
50
- value: "checkin",
51
- text: __("microsub.settings.types.checkin"),
52
- checked: channel.settings.excludeTypes | includes("checkin")
35
+ label: __("microsub.settings.types.checkin"),
36
+ value: "checkin"
53
37
  }
54
38
  ]
55
- }) | checkboxes }}
39
+ }) }}
56
40
 
57
- {{ field({
58
- label: {
59
- text: __("microsub.settings.excludeRegex")
60
- },
61
- hint: {
62
- text: __("microsub.settings.excludeRegexHelp")
63
- },
64
- input: {
65
- id: "excludeRegex",
66
- name: "excludeRegex",
67
- value: channel.settings.excludeRegex
68
- }
41
+ {{ input({
42
+ id: "excludeRegex",
43
+ name: "excludeRegex",
44
+ label: __("microsub.settings.excludeRegex"),
45
+ hint: __("microsub.settings.excludeRegexHelp"),
46
+ value: channel.settings.excludeRegex
69
47
  }) }}
70
48
 
71
49
  <div class="button-group">
72
50
  {{ button({
73
51
  text: __("microsub.settings.save")
74
52
  }) }}
75
- <a href="{{ request.baseUrl }}/channels/{{ channel.uid }}" class="button button--secondary">
53
+ <a href="{{ baseUrl }}/channels/{{ channel.uid }}" class="button button--secondary">
76
54
  {{ __("Cancel") }}
77
55
  </a>
78
56
  </div>