@rmdes/indiekit-endpoint-microsub 1.0.36 → 1.0.38

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 CHANGED
@@ -1014,3 +1014,290 @@
1014
1014
  background: #7c3aed20;
1015
1015
  color: #7c3aed;
1016
1016
  }
1017
+
1018
+ /* ==========================================================================
1019
+ View Switcher
1020
+ ========================================================================== */
1021
+
1022
+ .view-switcher {
1023
+ display: flex;
1024
+ gap: var(--space-xs);
1025
+ padding: var(--space-xs) 0;
1026
+ }
1027
+
1028
+ .view-switcher__button {
1029
+ align-items: center;
1030
+ border: 1px solid var(--color-border, #ddd);
1031
+ border-radius: var(--border-radius);
1032
+ color: var(--color-text-muted);
1033
+ display: flex;
1034
+ justify-content: center;
1035
+ padding: var(--space-xs);
1036
+ text-decoration: none;
1037
+ transition: background-color 0.2s ease, color 0.2s ease;
1038
+ }
1039
+
1040
+ .view-switcher__button:hover {
1041
+ background: var(--color-offset);
1042
+ color: var(--color-text);
1043
+ }
1044
+
1045
+ .view-switcher__button--active {
1046
+ background: var(--color-primary, #333);
1047
+ border-color: var(--color-primary, #333);
1048
+ color: #fff;
1049
+ }
1050
+
1051
+ .view-switcher__button--active:hover {
1052
+ background: var(--color-primary, #333);
1053
+ color: #fff;
1054
+ }
1055
+
1056
+ /* ==========================================================================
1057
+ Timeline View (all channels chronological)
1058
+ ========================================================================== */
1059
+
1060
+ .timeline-view {
1061
+ display: flex;
1062
+ flex-direction: column;
1063
+ gap: var(--space-m);
1064
+ }
1065
+
1066
+ .timeline-view__header {
1067
+ align-items: center;
1068
+ display: flex;
1069
+ flex-wrap: wrap;
1070
+ gap: var(--space-s);
1071
+ justify-content: space-between;
1072
+ }
1073
+
1074
+ .timeline-view__item {
1075
+ border-radius: var(--border-radius);
1076
+ position: relative;
1077
+ }
1078
+
1079
+ .timeline-view__item .item-card {
1080
+ border-left: none;
1081
+ }
1082
+
1083
+ .timeline-view__channel-label {
1084
+ display: block;
1085
+ font-size: 0.75rem;
1086
+ font-weight: 600;
1087
+ padding: 0 var(--space-s) var(--space-xs);
1088
+ }
1089
+
1090
+ .timeline-view__filter {
1091
+ position: relative;
1092
+ }
1093
+
1094
+ .timeline-view__filter-form {
1095
+ background: var(--color-background);
1096
+ border: 1px solid var(--color-border, #ddd);
1097
+ border-radius: var(--border-radius);
1098
+ display: flex;
1099
+ flex-direction: column;
1100
+ gap: var(--space-xs);
1101
+ min-width: 200px;
1102
+ padding: var(--space-s);
1103
+ position: absolute;
1104
+ right: 0;
1105
+ top: 100%;
1106
+ z-index: 10;
1107
+ }
1108
+
1109
+ .timeline-view__filter-label {
1110
+ align-items: center;
1111
+ cursor: pointer;
1112
+ display: flex;
1113
+ gap: var(--space-xs);
1114
+ }
1115
+
1116
+ .timeline-view__filter-color {
1117
+ border-radius: 2px;
1118
+ display: inline-block;
1119
+ height: 12px;
1120
+ width: 12px;
1121
+ }
1122
+
1123
+ /* ==========================================================================
1124
+ Compact Item Card (Deck view)
1125
+ ========================================================================== */
1126
+
1127
+ .item-card-compact {
1128
+ background: var(--color-background);
1129
+ border: 1px solid var(--color-border, #e0e0e0);
1130
+ border-radius: var(--border-radius);
1131
+ overflow: hidden;
1132
+ transition: background-color 0.2s ease;
1133
+ }
1134
+
1135
+ .item-card-compact:hover {
1136
+ background: var(--color-offset);
1137
+ }
1138
+
1139
+ .item-card-compact--read {
1140
+ opacity: 0.7;
1141
+ }
1142
+
1143
+ .item-card-compact:not(.item-card-compact--read) {
1144
+ border-left: 3px solid rgba(255, 204, 0, 0.8);
1145
+ }
1146
+
1147
+ .item-card-compact__link {
1148
+ color: inherit;
1149
+ display: flex;
1150
+ gap: var(--space-xs);
1151
+ padding: var(--space-xs) var(--space-s);
1152
+ text-decoration: none;
1153
+ }
1154
+
1155
+ .item-card-compact__photo {
1156
+ border-radius: var(--border-radius);
1157
+ flex-shrink: 0;
1158
+ height: 60px;
1159
+ object-fit: cover;
1160
+ width: 60px;
1161
+ }
1162
+
1163
+ .item-card-compact__body {
1164
+ flex: 1;
1165
+ min-width: 0;
1166
+ overflow: hidden;
1167
+ }
1168
+
1169
+ .item-card-compact__title {
1170
+ -webkit-box-orient: vertical;
1171
+ -webkit-line-clamp: 2;
1172
+ display: -webkit-box;
1173
+ font-size: 0.875rem;
1174
+ font-weight: 600;
1175
+ line-height: 1.3;
1176
+ margin: 0;
1177
+ overflow: hidden;
1178
+ }
1179
+
1180
+ .item-card-compact__text {
1181
+ -webkit-box-orient: vertical;
1182
+ -webkit-line-clamp: 2;
1183
+ color: var(--color-text-muted);
1184
+ display: -webkit-box;
1185
+ font-size: 0.8125rem;
1186
+ line-height: 1.4;
1187
+ margin: 0;
1188
+ overflow: hidden;
1189
+ }
1190
+
1191
+ .item-card-compact__meta {
1192
+ color: var(--color-text-muted);
1193
+ display: flex;
1194
+ font-size: 0.75rem;
1195
+ gap: var(--space-xs);
1196
+ margin-top: 2px;
1197
+ }
1198
+
1199
+ .item-card-compact__source {
1200
+ font-weight: 500;
1201
+ overflow: hidden;
1202
+ text-overflow: ellipsis;
1203
+ white-space: nowrap;
1204
+ }
1205
+
1206
+ .item-card-compact__date {
1207
+ flex-shrink: 0;
1208
+ white-space: nowrap;
1209
+ }
1210
+
1211
+ .item-card-compact__unread {
1212
+ color: rgba(255, 204, 0, 0.9);
1213
+ flex-shrink: 0;
1214
+ font-size: 0.625rem;
1215
+ }
1216
+
1217
+ /* ==========================================================================
1218
+ Deck View (TweetDeck-style columns)
1219
+ ========================================================================== */
1220
+
1221
+ .deck {
1222
+ display: flex;
1223
+ flex-direction: column;
1224
+ gap: var(--space-m);
1225
+ }
1226
+
1227
+ .deck__header {
1228
+ align-items: center;
1229
+ display: flex;
1230
+ flex-wrap: wrap;
1231
+ gap: var(--space-s);
1232
+ justify-content: space-between;
1233
+ }
1234
+
1235
+ .deck__columns {
1236
+ display: flex;
1237
+ gap: var(--space-m);
1238
+ overflow-x: auto;
1239
+ padding-bottom: var(--space-s);
1240
+ scroll-snap-type: x mandatory;
1241
+ }
1242
+
1243
+ .deck__column {
1244
+ flex-shrink: 0;
1245
+ scroll-snap-align: start;
1246
+ width: 320px;
1247
+ }
1248
+
1249
+ .deck__column-header {
1250
+ align-items: center;
1251
+ background: var(--color-offset);
1252
+ border-radius: var(--border-radius) var(--border-radius) 0 0;
1253
+ display: flex;
1254
+ gap: var(--space-s);
1255
+ justify-content: space-between;
1256
+ padding: var(--space-s) var(--space-m);
1257
+ position: sticky;
1258
+ top: 0;
1259
+ z-index: 1;
1260
+ }
1261
+
1262
+ .deck__column-name {
1263
+ color: inherit;
1264
+ font-weight: 600;
1265
+ text-decoration: none;
1266
+ }
1267
+
1268
+ .deck__column-items {
1269
+ display: flex;
1270
+ flex-direction: column;
1271
+ gap: var(--space-xs);
1272
+ max-height: 80vh;
1273
+ overflow-y: auto;
1274
+ padding: var(--space-xs);
1275
+ }
1276
+
1277
+ .deck__column-empty {
1278
+ color: var(--color-text-muted);
1279
+ font-size: 0.875rem;
1280
+ padding: var(--space-m);
1281
+ text-align: center;
1282
+ }
1283
+
1284
+ .deck__column-more {
1285
+ display: block;
1286
+ margin-top: var(--space-xs);
1287
+ text-align: center;
1288
+ }
1289
+
1290
+ /* Deck settings */
1291
+ .deck-settings__channels {
1292
+ display: flex;
1293
+ flex-direction: column;
1294
+ gap: var(--space-xs);
1295
+ margin: var(--space-m) 0;
1296
+ }
1297
+
1298
+ .deck-settings__channel {
1299
+ align-items: center;
1300
+ cursor: pointer;
1301
+ display: flex;
1302
+ gap: var(--space-xs);
1303
+ }
package/index.js CHANGED
@@ -132,6 +132,10 @@ export default class MicrosubEndpoint {
132
132
  readerRouter.post("/actor/unfollow", readerController.unfollowActorAction);
133
133
  readerRouter.post("/api/mark-read", readerController.markAllRead);
134
134
  readerRouter.get("/opml", opmlController.exportOpml);
135
+ readerRouter.get("/timeline", readerController.timeline);
136
+ readerRouter.get("/deck", readerController.deck);
137
+ readerRouter.get("/deck/settings", readerController.deckSettings);
138
+ readerRouter.post("/deck/settings", readerController.saveDeckSettings);
135
139
  router.use("/reader", readerRouter);
136
140
 
137
141
  return router;
@@ -171,6 +175,7 @@ export default class MicrosubEndpoint {
171
175
  indiekit.addCollection("microsub_notifications");
172
176
  indiekit.addCollection("microsub_muted");
173
177
  indiekit.addCollection("microsub_blocked");
178
+ indiekit.addCollection("microsub_deck_config");
174
179
 
175
180
  console.info("[Microsub] Registered MongoDB collections");
176
181
 
@@ -9,6 +9,7 @@ import { ObjectId } from "mongodb";
9
9
  import { refreshFeedNow } from "../polling/scheduler.js";
10
10
  import {
11
11
  getChannels,
12
+ getChannelsWithColors,
12
13
  getChannel,
13
14
  createChannel,
14
15
  updateChannelSettings,
@@ -24,6 +25,7 @@ import {
24
25
  } from "../storage/feeds.js";
25
26
  import {
26
27
  getTimelineItems,
28
+ getAllTimelineItems,
27
29
  getItemById,
28
30
  markItemsRead,
29
31
  countReadItems,
@@ -35,6 +37,8 @@ import {
35
37
  validateExcludeTypes,
36
38
  validateExcludeRegex,
37
39
  } from "../utils/validation.js";
40
+ import { proxyItemImages } from "../media/proxy.js";
41
+ import { getDeckConfig, saveDeckConfig } from "../storage/deck.js";
38
42
 
39
43
  /**
40
44
  * Reader index - redirect to channels
@@ -42,7 +46,10 @@ import {
42
46
  * @param {object} response - Express response
43
47
  */
44
48
  export async function index(request, response) {
45
- response.redirect(`${request.baseUrl}/channels`);
49
+ const lastView = request.session?.microsubView || "channels";
50
+ const validViews = ["channels", "deck", "timeline"];
51
+ const view = validViews.includes(lastView) ? lastView : "channels";
52
+ response.redirect(`${request.baseUrl}/${view}`);
46
53
  }
47
54
 
48
55
  /**
@@ -56,10 +63,14 @@ export async function channels(request, response) {
56
63
 
57
64
  const channelList = await getChannels(application, userId);
58
65
 
66
+ if (request.session) request.session.microsubView = "channels";
67
+
59
68
  response.render("reader", {
60
- title: request.__("microsub.reader.title"),
69
+ title: request.__("microsub.views.channels"),
61
70
  channels: channelList,
62
71
  baseUrl: request.baseUrl,
72
+ readerBaseUrl: request.baseUrl,
73
+ activeView: "channels",
63
74
  });
64
75
  }
65
76
 
@@ -72,6 +83,8 @@ export async function newChannel(request, response) {
72
83
  response.render("channel-new", {
73
84
  title: request.__("microsub.channels.new"),
74
85
  baseUrl: request.baseUrl,
86
+ readerBaseUrl: request.baseUrl,
87
+ activeView: "channels",
75
88
  });
76
89
  }
77
90
 
@@ -119,6 +132,14 @@ export async function channel(request, response) {
119
132
  showRead: showReadItems,
120
133
  });
121
134
 
135
+ // Proxy images through media endpoint for privacy
136
+ const proxyBaseUrl = application.url;
137
+ if (proxyBaseUrl && timeline.items) {
138
+ timeline.items = timeline.items.map((item) =>
139
+ proxyItemImages(item, proxyBaseUrl),
140
+ );
141
+ }
142
+
122
143
  // Count read items to show "View read items" button
123
144
  const readCount = await countReadItems(
124
145
  application,
@@ -134,6 +155,8 @@ export async function channel(request, response) {
134
155
  readCount,
135
156
  showRead: showReadItems,
136
157
  baseUrl: request.baseUrl,
158
+ readerBaseUrl: request.baseUrl,
159
+ activeView: "channels",
137
160
  });
138
161
  }
139
162
 
@@ -159,6 +182,8 @@ export async function settings(request, response) {
159
182
  }),
160
183
  channel: channelDocument,
161
184
  baseUrl: request.baseUrl,
185
+ readerBaseUrl: request.baseUrl,
186
+ activeView: "channels",
162
187
  });
163
188
  }
164
189
 
@@ -246,6 +271,8 @@ export async function feeds(request, response) {
246
271
  channel: channelDocument,
247
272
  feeds: feedList,
248
273
  baseUrl: request.baseUrl,
274
+ readerBaseUrl: request.baseUrl,
275
+ activeView: "channels",
249
276
  });
250
277
  }
251
278
 
@@ -332,6 +359,8 @@ export async function item(request, response) {
332
359
  item: itemDocument,
333
360
  channel,
334
361
  baseUrl: request.baseUrl,
362
+ readerBaseUrl: request.baseUrl,
363
+ activeView: "channels",
335
364
  });
336
365
  }
337
366
 
@@ -442,6 +471,8 @@ export async function compose(request, response) {
442
471
  bookmarkOf: ensureString(bookmarkOf || bookmark),
443
472
  syndicationTargets,
444
473
  baseUrl: request.baseUrl,
474
+ readerBaseUrl: request.baseUrl,
475
+ activeView: "channels",
445
476
  });
446
477
  }
447
478
 
@@ -615,6 +646,8 @@ export async function searchPage(request, response) {
615
646
  title: request.__("microsub.search.title"),
616
647
  channels: channelList,
617
648
  baseUrl: request.baseUrl,
649
+ readerBaseUrl: request.baseUrl,
650
+ activeView: "channels",
618
651
  });
619
652
  }
620
653
 
@@ -651,6 +684,8 @@ export async function searchFeeds(request, response) {
651
684
  discoveryError,
652
685
  searched: true,
653
686
  baseUrl: request.baseUrl,
687
+ readerBaseUrl: request.baseUrl,
688
+ activeView: "channels",
654
689
  });
655
690
  }
656
691
 
@@ -682,6 +717,8 @@ export async function subscribe(request, response) {
682
717
  query: url,
683
718
  validationError: validation.error,
684
719
  baseUrl: request.baseUrl,
720
+ readerBaseUrl: request.baseUrl,
721
+ activeView: "channels",
685
722
  });
686
723
  }
687
724
 
@@ -772,6 +809,8 @@ export async function editFeedForm(request, response) {
772
809
  channel: channelDocument,
773
810
  feed,
774
811
  baseUrl: request.baseUrl,
812
+ readerBaseUrl: request.baseUrl,
813
+ activeView: "channels",
775
814
  });
776
815
  }
777
816
 
@@ -807,6 +846,8 @@ export async function updateFeedUrl(request, response) {
807
846
  feed,
808
847
  error: validation.error,
809
848
  baseUrl: request.baseUrl,
849
+ readerBaseUrl: request.baseUrl,
850
+ activeView: "channels",
810
851
  });
811
852
  }
812
853
 
@@ -986,6 +1027,8 @@ export async function actorProfile(request, response) {
986
1027
  isFollowing,
987
1028
  canFollow,
988
1029
  baseUrl: request.baseUrl,
1030
+ readerBaseUrl: request.baseUrl,
1031
+ activeView: "channels",
989
1032
  });
990
1033
  } catch (error) {
991
1034
  console.error(`[Microsub] Actor profile fetch failed: ${error.message}`);
@@ -997,6 +1040,8 @@ export async function actorProfile(request, response) {
997
1040
  isFollowing,
998
1041
  canFollow,
999
1042
  baseUrl: request.baseUrl,
1043
+ readerBaseUrl: request.baseUrl,
1044
+ activeView: "channels",
1000
1045
  error: "Could not fetch this actor's profile. They may have restricted access.",
1001
1046
  });
1002
1047
  }
@@ -1050,6 +1095,181 @@ export async function unfollowActorAction(request, response) {
1050
1095
  );
1051
1096
  }
1052
1097
 
1098
+ /**
1099
+ * Timeline view - all channels chronologically
1100
+ * @param {object} request - Express request
1101
+ * @param {object} response - Express response
1102
+ */
1103
+ export async function timeline(request, response) {
1104
+ const { application } = request.app.locals;
1105
+ const userId = getUserId(request);
1106
+ const { before, after } = request.query;
1107
+
1108
+ // Get channels with colors for filtering UI and item decoration
1109
+ const channelList = await getChannelsWithColors(application, userId);
1110
+
1111
+ // Build channel lookup map (ObjectId string -> { name, color })
1112
+ const channelMap = new Map();
1113
+ for (const ch of channelList) {
1114
+ channelMap.set(ch._id.toString(), { name: ch.name, color: ch.color });
1115
+ }
1116
+
1117
+ // Parse excluded channel IDs from query params
1118
+ const excludeParam = request.query.exclude;
1119
+ const excludeIds = excludeParam
1120
+ ? (Array.isArray(excludeParam) ? excludeParam : [excludeParam])
1121
+ : [];
1122
+
1123
+ // Exclude the notifications channel by default
1124
+ const notificationsChannel = channelList.find((ch) => ch.uid === "notifications");
1125
+ const excludeChannelIds = [...excludeIds];
1126
+ if (notificationsChannel && !excludeChannelIds.includes(notificationsChannel._id.toString())) {
1127
+ excludeChannelIds.push(notificationsChannel._id.toString());
1128
+ }
1129
+
1130
+ const result = await getAllTimelineItems(application, {
1131
+ before,
1132
+ after,
1133
+ userId,
1134
+ excludeChannelIds,
1135
+ });
1136
+
1137
+ // Proxy images
1138
+ const proxyBaseUrl = application.url;
1139
+ if (proxyBaseUrl && result.items) {
1140
+ result.items = result.items.map((item) => proxyItemImages(item, proxyBaseUrl));
1141
+ }
1142
+
1143
+ // Decorate items with channel name and color
1144
+ for (const item of result.items) {
1145
+ if (item._channelId) {
1146
+ const info = channelMap.get(item._channelId);
1147
+ if (info) {
1148
+ item._channelName = info.name;
1149
+ item._channelColor = info.color;
1150
+ }
1151
+ }
1152
+ }
1153
+
1154
+ // Set view preference cookie
1155
+ if (request.session) request.session.microsubView = "timeline";
1156
+
1157
+ response.render("timeline", {
1158
+ title: "Timeline",
1159
+ channels: channelList,
1160
+ items: result.items,
1161
+ paging: result.paging,
1162
+ excludeIds,
1163
+ baseUrl: request.baseUrl,
1164
+ readerBaseUrl: request.baseUrl,
1165
+ activeView: "timeline",
1166
+ });
1167
+ }
1168
+
1169
+ /**
1170
+ * Deck view - TweetDeck-style columns
1171
+ * @param {object} request - Express request
1172
+ * @param {object} response - Express response
1173
+ */
1174
+ export async function deck(request, response) {
1175
+ const { application } = request.app.locals;
1176
+ const userId = getUserId(request);
1177
+
1178
+ const channelList = await getChannelsWithColors(application, userId);
1179
+ const deckConfig = await getDeckConfig(application, userId);
1180
+
1181
+ // Determine which channels to show as columns
1182
+ let columnChannels;
1183
+ if (deckConfig?.columns?.length > 0) {
1184
+ // Use saved config order
1185
+ const channelMap = new Map(channelList.map((ch) => [ch._id.toString(), ch]));
1186
+ columnChannels = deckConfig.columns
1187
+ .map((col) => channelMap.get(col.channelId.toString()))
1188
+ .filter(Boolean);
1189
+ } else {
1190
+ // Default: all channels except notifications
1191
+ columnChannels = channelList.filter((ch) => ch.uid !== "notifications");
1192
+ }
1193
+
1194
+ // Fetch items for each column (limited to 10 per column for performance)
1195
+ const proxyBaseUrl = application.url;
1196
+ const columns = await Promise.all(
1197
+ columnChannels.map(async (channel) => {
1198
+ const result = await getTimelineItems(application, channel._id, {
1199
+ userId,
1200
+ limit: 10,
1201
+ });
1202
+
1203
+ if (proxyBaseUrl && result.items) {
1204
+ result.items = result.items.map((item) =>
1205
+ proxyItemImages(item, proxyBaseUrl),
1206
+ );
1207
+ }
1208
+
1209
+ return {
1210
+ channel,
1211
+ items: result.items,
1212
+ paging: result.paging,
1213
+ };
1214
+ }),
1215
+ );
1216
+
1217
+ // Set view preference cookie
1218
+ if (request.session) request.session.microsubView = "deck";
1219
+
1220
+ response.render("deck", {
1221
+ title: "Deck",
1222
+ columns,
1223
+ baseUrl: request.baseUrl,
1224
+ readerBaseUrl: request.baseUrl,
1225
+ activeView: "deck",
1226
+ });
1227
+ }
1228
+
1229
+ /**
1230
+ * Deck settings page
1231
+ * @param {object} request - Express request
1232
+ * @param {object} response - Express response
1233
+ */
1234
+ export async function deckSettings(request, response) {
1235
+ const { application } = request.app.locals;
1236
+ const userId = getUserId(request);
1237
+
1238
+ const channelList = await getChannelsWithColors(application, userId);
1239
+ const deckConfig = await getDeckConfig(application, userId);
1240
+
1241
+ const selectedIds = deckConfig?.columns
1242
+ ? deckConfig.columns.map((col) => col.channelId.toString())
1243
+ : channelList.filter((ch) => ch.uid !== "notifications").map((ch) => ch._id.toString());
1244
+
1245
+ response.render("deck-settings", {
1246
+ title: "Deck settings",
1247
+ channels: channelList,
1248
+ selectedIds,
1249
+ baseUrl: request.baseUrl,
1250
+ readerBaseUrl: request.baseUrl,
1251
+ activeView: "deck",
1252
+ });
1253
+ }
1254
+
1255
+ /**
1256
+ * Save deck settings
1257
+ * @param {object} request - Express request
1258
+ * @param {object} response - Express response
1259
+ */
1260
+ export async function saveDeckSettings(request, response) {
1261
+ const { application } = request.app.locals;
1262
+ const userId = getUserId(request);
1263
+
1264
+ let { columns } = request.body;
1265
+ if (!columns) columns = [];
1266
+ if (!Array.isArray(columns)) columns = [columns];
1267
+
1268
+ await saveDeckConfig(application, userId, columns);
1269
+
1270
+ response.redirect(`${request.baseUrl}/deck`);
1271
+ }
1272
+
1053
1273
  export const readerController = {
1054
1274
  index,
1055
1275
  channels,
@@ -1077,4 +1297,8 @@ export const readerController = {
1077
1297
  actorProfile,
1078
1298
  followActorAction,
1079
1299
  unfollowActorAction,
1300
+ timeline,
1301
+ deck,
1302
+ deckSettings,
1303
+ saveDeckSettings,
1080
1304
  };
@@ -7,6 +7,32 @@ import { ObjectId } from "mongodb";
7
7
 
8
8
  import { generateChannelUid } from "../utils/jf2.js";
9
9
 
10
+ /**
11
+ * Channel color palette for visual identification.
12
+ * Colors chosen for accessibility on white/light backgrounds as 4px left borders.
13
+ */
14
+ const CHANNEL_COLORS = [
15
+ "#4A90D9", // blue
16
+ "#E5604E", // red
17
+ "#50B86C", // green
18
+ "#E8A838", // amber
19
+ "#9B59B6", // purple
20
+ "#00B8D4", // cyan
21
+ "#F06292", // pink
22
+ "#78909C", // blue-grey
23
+ "#FF7043", // deep orange
24
+ "#26A69A", // teal
25
+ ];
26
+
27
+ /**
28
+ * Get a color for a channel based on its order
29
+ * @param {number} order - Channel order index
30
+ * @returns {string} Hex color
31
+ */
32
+ export function getChannelColor(order) {
33
+ return CHANNEL_COLORS[Math.abs(order) % CHANNEL_COLORS.length];
34
+ }
35
+
10
36
  import { deleteFeedsForChannel } from "./feeds.js";
11
37
  import { deleteItemsForChannel } from "./items.js";
12
38
 
@@ -65,11 +91,14 @@ export async function createChannel(application, { name, userId }) {
65
91
 
66
92
  const order = maxOrderResult.length > 0 ? maxOrderResult[0].order + 1 : 0;
67
93
 
94
+ const color = getChannelColor(order);
95
+
68
96
  const channel = {
69
97
  uid,
70
98
  name,
71
99
  userId,
72
100
  order,
101
+ color,
73
102
  settings: {
74
103
  excludeTypes: [],
75
104
  excludeRegex: undefined,
@@ -141,6 +170,56 @@ export async function getChannels(application, userId) {
141
170
  return channelsWithCounts;
142
171
  }
143
172
 
173
+ /**
174
+ * Get channels with color field ensured (fallback for older channels without color).
175
+ * Returns full channel documents with _id, unlike getChannels() which returns simplified objects.
176
+ * @param {object} application - Indiekit application
177
+ * @param {string} [userId] - User ID
178
+ * @returns {Promise<Array>} Channels with color and unread fields
179
+ */
180
+ export async function getChannelsWithColors(application, userId) {
181
+ const collection = getCollection(application);
182
+ const itemsCollection = getItemsCollection(application);
183
+
184
+ const filter = userId ? { userId } : {};
185
+ const channels = await collection
186
+ // eslint-disable-next-line unicorn/no-array-callback-reference -- filter is MongoDB query object
187
+ .find(filter)
188
+ // eslint-disable-next-line unicorn/no-array-sort -- MongoDB cursor method, not Array#sort
189
+ .sort({ order: 1 })
190
+ .toArray();
191
+
192
+ const cutoffDate = new Date();
193
+ cutoffDate.setDate(cutoffDate.getDate() - UNREAD_RETENTION_DAYS);
194
+
195
+ const enriched = await Promise.all(
196
+ channels.map(async (channel, index) => {
197
+ const unreadCount = await itemsCollection.countDocuments({
198
+ channelId: channel._id,
199
+ readBy: { $ne: userId },
200
+ published: { $gte: cutoffDate },
201
+ _stripped: { $ne: true },
202
+ });
203
+
204
+ return {
205
+ ...channel,
206
+ color: channel.color || getChannelColor(index),
207
+ unread: unreadCount > 0 ? unreadCount : false,
208
+ };
209
+ }),
210
+ );
211
+
212
+ // Notifications first, then by order
213
+ const notifications = enriched.find((c) => c.uid === "notifications");
214
+ const others = enriched.filter((c) => c.uid !== "notifications");
215
+
216
+ if (notifications) {
217
+ return [notifications, ...others];
218
+ }
219
+
220
+ return enriched;
221
+ }
222
+
144
223
  /**
145
224
  * Get a single channel by UID
146
225
  * @param {object} application - Indiekit application
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Deck configuration storage
3
+ * @module storage/deck
4
+ */
5
+
6
+ import { ObjectId } from "mongodb";
7
+
8
+ /**
9
+ * Get deck config collection
10
+ * @param {object} application - Indiekit application
11
+ * @returns {object} MongoDB collection
12
+ */
13
+ function getCollection(application) {
14
+ return application.collections.get("microsub_deck_config");
15
+ }
16
+
17
+ /**
18
+ * Get deck configuration for a user
19
+ * @param {object} application - Indiekit application
20
+ * @param {string} userId - User ID
21
+ * @returns {Promise<object|null>} Deck config or null
22
+ */
23
+ export async function getDeckConfig(application, userId) {
24
+ const collection = getCollection(application);
25
+ return collection.findOne({ userId });
26
+ }
27
+
28
+ /**
29
+ * Save deck configuration
30
+ * @param {object} application - Indiekit application
31
+ * @param {string} userId - User ID
32
+ * @param {Array<string>} channelIds - Ordered array of channel ObjectId strings
33
+ * @returns {Promise<void>}
34
+ */
35
+ export async function saveDeckConfig(application, userId, channelIds) {
36
+ const collection = getCollection(application);
37
+ const columns = channelIds.map((id, order) => ({
38
+ channelId: new ObjectId(id),
39
+ order,
40
+ }));
41
+
42
+ await collection.updateOne(
43
+ { userId },
44
+ {
45
+ $set: {
46
+ columns,
47
+ updatedAt: new Date().toISOString(),
48
+ },
49
+ $setOnInsert: {
50
+ userId,
51
+ createdAt: new Date().toISOString(),
52
+ },
53
+ },
54
+ { upsert: true },
55
+ );
56
+ }
@@ -149,6 +149,69 @@ export async function getTimelineItems(application, channelId, options = {}) {
149
149
  };
150
150
  }
151
151
 
152
+ /**
153
+ * Get timeline items across ALL channels, sorted chronologically
154
+ * @param {object} application - Indiekit application
155
+ * @param {object} options - Query options
156
+ * @param {string} [options.before] - Before cursor
157
+ * @param {string} [options.after] - After cursor
158
+ * @param {number} [options.limit] - Items per page
159
+ * @param {string} [options.userId] - User ID for read state
160
+ * @param {boolean} [options.showRead] - Include read items
161
+ * @param {Array<string>} [options.excludeChannelIds] - Channel IDs to exclude
162
+ * @returns {Promise<object>} { items, paging }
163
+ */
164
+ export async function getAllTimelineItems(application, options = {}) {
165
+ const collection = getCollection(application);
166
+ const limit = parseLimit(options.limit);
167
+
168
+ // Base query - no channelId filter (cross-channel)
169
+ const baseQuery = { _stripped: { $ne: true } };
170
+
171
+ if (options.userId && !options.showRead) {
172
+ baseQuery.readBy = { $ne: options.userId };
173
+ }
174
+
175
+ // Exclude specific channels if requested
176
+ if (options.excludeChannelIds?.length > 0) {
177
+ baseQuery.channelId = {
178
+ $nin: options.excludeChannelIds.map(
179
+ (id) => (typeof id === "string" ? new ObjectId(id) : id),
180
+ ),
181
+ };
182
+ }
183
+
184
+ const query = buildPaginationQuery({
185
+ before: options.before,
186
+ after: options.after,
187
+ baseQuery,
188
+ });
189
+
190
+ const sort = buildPaginationSort(options.before);
191
+
192
+ const items = await collection
193
+ // eslint-disable-next-line unicorn/no-array-callback-reference -- query is MongoDB query object
194
+ .find(query)
195
+ // eslint-disable-next-line unicorn/no-array-sort -- MongoDB cursor method, not Array#sort
196
+ .sort(sort)
197
+ .limit(limit + 1)
198
+ .toArray();
199
+
200
+ const hasMore = items.length > limit;
201
+ if (hasMore) {
202
+ items.pop();
203
+ }
204
+
205
+ const jf2Items = items.map((item) => transformToJf2(item, options.userId));
206
+
207
+ const paging = generatePagingCursors(items, limit, hasMore, options.before);
208
+
209
+ return {
210
+ items: jf2Items,
211
+ paging,
212
+ };
213
+ }
214
+
152
215
  /**
153
216
  * Extract URL string from a media value
154
217
  * @param {object|string} media - Media value (can be string URL or object)
@@ -207,6 +270,7 @@ function transformToJf2(item, userId) {
207
270
  published: item.published?.toISOString(), // Convert Date to ISO string
208
271
  _id: item._id.toString(),
209
272
  _is_read: userId ? item.readBy?.includes(userId) : false,
273
+ _channelId: item.channelId?.toString(),
210
274
  };
211
275
 
212
276
  // Optional fields
package/locales/en.json CHANGED
@@ -94,6 +94,11 @@
94
94
  "title": "Preview",
95
95
  "subscribe": "Subscribe to this feed"
96
96
  },
97
+ "views": {
98
+ "channels": "Channels",
99
+ "deck": "Deck",
100
+ "timeline": "Timeline"
101
+ },
97
102
  "error": {
98
103
  "channelNotFound": "Channel not found",
99
104
  "feedNotFound": "Feed not found",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-microsub",
3
- "version": "1.0.36",
3
+ "version": "1.0.38",
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/actor.njk CHANGED
@@ -121,8 +121,10 @@
121
121
  {# Tags #}
122
122
  {% if item.category and item.category.length > 0 %}
123
123
  <div class="item-card__categories">
124
- {% for cat in item.category | slice(0, 5) %}
124
+ {% for cat in item.category %}
125
+ {% if loop.index0 < 5 %}
125
126
  <span class="item-card__category">#{{ cat }}</span>
127
+ {% endif %}
126
128
  {% endfor %}
127
129
  </div>
128
130
  {% endif %}
@@ -131,9 +133,11 @@
131
133
  {% if item.photo and item.photo.length > 0 %}
132
134
  {% set photoCount = item.photo.length if item.photo.length <= 4 else 4 %}
133
135
  <div class="item-card__photos item-card__photos--{{ photoCount }}">
134
- {% for photo in item.photo | slice(0, 4) %}
136
+ {% for photo in item.photo %}
137
+ {% if loop.index0 < 4 %}
135
138
  <img src="{{ photo }}" alt="" class="item-card__photo" loading="lazy"
136
139
  onerror="this.parentElement.removeChild(this)">
140
+ {% endif %}
137
141
  {% endfor %}
138
142
  </div>
139
143
  {% endif %}
@@ -0,0 +1,33 @@
1
+ {% extends "layouts/reader.njk" %}
2
+
3
+ {% block reader %}
4
+ <div class="settings">
5
+ <header>
6
+ <a href="{{ baseUrl }}/deck" class="back-link">
7
+ {{ __("microsub.views.deck") }}
8
+ </a>
9
+ <h1>Deck columns</h1>
10
+ </header>
11
+
12
+ <form action="{{ baseUrl }}/deck/settings" method="POST">
13
+ <p>Select which channels appear as columns in your deck, and their order.</p>
14
+
15
+ <div class="deck-settings__channels">
16
+ {% for channel in channels %}
17
+ {% if channel.uid !== "notifications" %}
18
+ <label class="deck-settings__channel">
19
+ <input type="checkbox" name="columns" value="{{ channel._id }}"
20
+ {% if channel._id.toString() in selectedIds %}checked{% endif %}>
21
+ <span class="timeline-view__filter-color" style="background: {{ channel.color }}"></span>
22
+ {{ channel.name }}
23
+ </label>
24
+ {% endif %}
25
+ {% endfor %}
26
+ </div>
27
+
28
+ <button type="submit" class="button button--primary">
29
+ Save deck configuration
30
+ </button>
31
+ </form>
32
+ </div>
33
+ {% endblock %}
package/views/deck.njk ADDED
@@ -0,0 +1,52 @@
1
+ {% extends "layouts/reader.njk" %}
2
+
3
+ {% block reader %}
4
+ <div class="deck">
5
+ <header class="deck__header">
6
+ <h1>{{ __("microsub.views.deck") }}</h1>
7
+ <a href="{{ baseUrl }}/deck/settings" class="button button--secondary button--small">
8
+ Configure columns
9
+ </a>
10
+ </header>
11
+
12
+ {% if columns.length > 0 %}
13
+ <div class="deck__columns">
14
+ {% for col in columns %}
15
+ <div class="deck__column" data-channel-uid="{{ col.channel.uid }}">
16
+ <div class="deck__column-header" style="border-top: 3px solid {{ col.channel.color or '#ccc' }}">
17
+ <a href="{{ baseUrl }}/channels/{{ col.channel.uid }}" class="deck__column-name">
18
+ {{ col.channel.name }}
19
+ </a>
20
+ {% if col.channel.unread %}
21
+ <span class="reader__channel-badge{% if col.channel.unread === true %} reader__channel-badge--dot{% endif %}">
22
+ {% if col.channel.unread !== true %}{{ col.channel.unread }}{% endif %}
23
+ </span>
24
+ {% endif %}
25
+ </div>
26
+ <div class="deck__column-items">
27
+ {% for item in col.items %}
28
+ {% include "partials/item-card-compact.njk" %}
29
+ {% endfor %}
30
+ {% if col.items.length === 0 %}
31
+ <p class="deck__column-empty">No unread items</p>
32
+ {% endif %}
33
+ {% if col.paging and col.paging.after %}
34
+ <a href="{{ baseUrl }}/channels/{{ col.channel.uid }}"
35
+ class="deck__column-more button button--secondary button--small">
36
+ View more
37
+ </a>
38
+ {% endif %}
39
+ </div>
40
+ </div>
41
+ {% endfor %}
42
+ </div>
43
+ {% else %}
44
+ <div class="reader__empty">
45
+ <p>No columns configured. Add channels to your deck.</p>
46
+ <a href="{{ baseUrl }}/deck/settings" class="button button--primary">
47
+ Configure deck
48
+ </a>
49
+ </div>
50
+ {% endif %}
51
+ </div>
52
+ {% endblock %}
@@ -6,5 +6,6 @@
6
6
 
7
7
  {% block content %}
8
8
  <link rel="stylesheet" href="/assets/@rmdes-indiekit-endpoint-microsub/styles.css">
9
+ {% include "partials/view-switcher.njk" %}
9
10
  {% block reader %}{% endblock %}
10
11
  {% endblock %}
@@ -0,0 +1,37 @@
1
+ {# Compact item card for deck columns #}
2
+ <article class="item-card-compact{% if item._is_read %} item-card-compact--read{% endif %}"
3
+ data-item-id="{{ item._id }}">
4
+ <a href="{{ readerBaseUrl }}/item/{{ item._id }}" class="item-card-compact__link">
5
+ {% if item.photo and item.photo.length > 0 %}
6
+ <img src="{{ item.photo[0] }}"
7
+ alt=""
8
+ class="item-card-compact__photo"
9
+ loading="lazy"
10
+ onerror="this.style.display='none'">
11
+ {% endif %}
12
+ <div class="item-card-compact__body">
13
+ {% if item.name %}
14
+ <h4 class="item-card-compact__title">{{ item.name }}</h4>
15
+ {% elif item.content %}
16
+ <p class="item-card-compact__text">
17
+ {% if item.content.text %}{{ item.content.text | truncate(80) }}{% elif item.content.html %}{{ item.content.html | safe | striptags | truncate(80) }}{% endif %}
18
+ </p>
19
+ {% endif %}
20
+ <div class="item-card-compact__meta">
21
+ {% if item._source %}
22
+ <span class="item-card-compact__source">{{ item._source.name or item._source.url }}</span>
23
+ {% elif item.author %}
24
+ <span class="item-card-compact__source">{{ item.author.name }}</span>
25
+ {% endif %}
26
+ {% if item.published %}
27
+ <time datetime="{{ item.published }}" class="item-card-compact__date">
28
+ {{ item.published | date("PP") }}
29
+ </time>
30
+ {% endif %}
31
+ </div>
32
+ </div>
33
+ {% if not item._is_read %}
34
+ <span class="item-card-compact__unread" aria-label="Unread"></span>
35
+ {% endif %}
36
+ </a>
37
+ </article>
@@ -111,8 +111,10 @@
111
111
  {# Categories/Tags #}
112
112
  {% if item.category and item.category.length > 0 %}
113
113
  <div class="item-card__categories">
114
- {% for cat in item.category | slice(0, 5) %}
114
+ {% for cat in item.category %}
115
+ {% if loop.index0 < 5 %}
115
116
  <span class="item-card__category">#{{ cat | replace("#", "") }}</span>
117
+ {% endif %}
116
118
  {% endfor %}
117
119
  </div>
118
120
  {% endif %}
@@ -121,12 +123,14 @@
121
123
  {% if item.photo and item.photo.length > 0 %}
122
124
  {% set photoCount = item.photo.length if item.photo.length <= 4 else 4 %}
123
125
  <div class="item-card__photos item-card__photos--{{ photoCount }}">
124
- {% for photo in item.photo | slice(0, 4) %}
126
+ {% for photo in item.photo %}
127
+ {% if loop.index0 < 4 %}
125
128
  <img src="{{ photo }}"
126
129
  alt=""
127
130
  class="item-card__photo"
128
131
  loading="lazy"
129
132
  onerror="this.parentElement.removeChild(this)">
133
+ {% endif %}
130
134
  {% endfor %}
131
135
  </div>
132
136
  {% endif %}
@@ -0,0 +1,25 @@
1
+ {# View mode switcher - icon toolbar #}
2
+ <nav class="view-switcher" aria-label="View mode">
3
+ <a href="{{ readerBaseUrl }}/channels"
4
+ class="view-switcher__button{% if activeView === 'channels' %} view-switcher__button--active{% endif %}"
5
+ title="Channels">
6
+ <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
7
+ <line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/>
8
+ <line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>
9
+ </svg>
10
+ </a>
11
+ <a href="{{ readerBaseUrl }}/deck"
12
+ class="view-switcher__button{% if activeView === 'deck' %} view-switcher__button--active{% endif %}"
13
+ title="Deck">
14
+ <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
15
+ <rect x="3" y="3" width="7" height="18" rx="1"/><rect x="14" y="3" width="7" height="18" rx="1"/>
16
+ </svg>
17
+ </a>
18
+ <a href="{{ readerBaseUrl }}/timeline"
19
+ class="view-switcher__button{% if activeView === 'timeline' %} view-switcher__button--active{% endif %}"
20
+ title="Timeline">
21
+ <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
22
+ <line x1="12" y1="2" x2="12" y2="22"/><polyline points="19 15 12 22 5 15"/>
23
+ </svg>
24
+ </a>
25
+ </nav>
@@ -0,0 +1,100 @@
1
+ {% extends "layouts/reader.njk" %}
2
+
3
+ {% block reader %}
4
+ <div class="timeline-view">
5
+ <header class="timeline-view__header">
6
+ <h1>{{ __("microsub.views.timeline") }}</h1>
7
+ <div class="timeline-view__actions">
8
+ {% if channels.length > 0 %}
9
+ <details class="timeline-view__filter">
10
+ <summary class="button button--secondary button--small">
11
+ Filter channels
12
+ </summary>
13
+ <form action="{{ baseUrl }}/timeline" method="GET" class="timeline-view__filter-form">
14
+ {% for ch in channels %}
15
+ {% if ch.uid !== "notifications" %}
16
+ <label class="timeline-view__filter-label">
17
+ <input type="checkbox" name="exclude" value="{{ ch._id }}"
18
+ {% if excludeIds and ch._id.toString() in excludeIds %}checked{% endif %}>
19
+ <span class="timeline-view__filter-color" style="background: {{ ch.color }}"></span>
20
+ {{ ch.name }}
21
+ </label>
22
+ {% endif %}
23
+ {% endfor %}
24
+ <button type="submit" class="button button--primary button--small">Apply</button>
25
+ </form>
26
+ </details>
27
+ {% endif %}
28
+ </div>
29
+ </header>
30
+
31
+ {% if items.length > 0 %}
32
+ <div class="timeline" id="timeline">
33
+ {% for item in items %}
34
+ <div class="timeline-view__item" style="border-left: 4px solid {{ item._channelColor or '#ccc' }}">
35
+ {% include "partials/item-card.njk" %}
36
+ {% if item._channelName %}
37
+ <span class="timeline-view__channel-label" style="color: {{ item._channelColor or '#888' }}">
38
+ {{ item._channelName }}
39
+ </span>
40
+ {% endif %}
41
+ </div>
42
+ {% endfor %}
43
+ </div>
44
+
45
+ {% if paging %}
46
+ <nav class="timeline__paging" aria-label="Pagination">
47
+ {% if paging.before %}
48
+ <a href="?before={{ paging.before }}" class="button button--secondary">
49
+ {{ __("microsub.reader.newer") }}
50
+ </a>
51
+ {% else %}
52
+ <span></span>
53
+ {% endif %}
54
+ {% if paging.after %}
55
+ <a href="?after={{ paging.after }}" class="button button--secondary">
56
+ {{ __("microsub.reader.older") }}
57
+ </a>
58
+ {% endif %}
59
+ </nav>
60
+ {% endif %}
61
+
62
+ {% else %}
63
+ <div class="reader__empty">
64
+ <p>{{ __("microsub.reader.empty") }}</p>
65
+ </div>
66
+ {% endif %}
67
+ </div>
68
+
69
+ <script type="module">
70
+ const timeline = document.getElementById('timeline');
71
+ if (timeline) {
72
+ const items = Array.from(timeline.querySelectorAll('.item-card'));
73
+ let currentIndex = -1;
74
+
75
+ function focusItem(index) {
76
+ if (items[currentIndex]) items[currentIndex].classList.remove('item-card--focused');
77
+ currentIndex = Math.max(0, Math.min(index, items.length - 1));
78
+ if (items[currentIndex]) {
79
+ items[currentIndex].classList.add('item-card--focused');
80
+ items[currentIndex].scrollIntoView({ behavior: 'smooth', block: 'center' });
81
+ }
82
+ }
83
+
84
+ document.addEventListener('keydown', (e) => {
85
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
86
+ switch(e.key) {
87
+ case 'j': e.preventDefault(); focusItem(currentIndex + 1); break;
88
+ case 'k': e.preventDefault(); focusItem(currentIndex - 1); break;
89
+ case 'o': case 'Enter':
90
+ e.preventDefault();
91
+ if (items[currentIndex]) {
92
+ const link = items[currentIndex].querySelector('.item-card__link');
93
+ if (link) link.click();
94
+ }
95
+ break;
96
+ }
97
+ });
98
+ }
99
+ </script>
100
+ {% endblock %}