@rmdes/indiekit-endpoint-microsub 1.0.37 → 1.0.39

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,334 @@
1014
1014
  background: #7c3aed20;
1015
1015
  color: #7c3aed;
1016
1016
  }
1017
+
1018
+ /* ==========================================================================
1019
+ Breadcrumbs
1020
+ ========================================================================== */
1021
+
1022
+ .breadcrumbs {
1023
+ margin-bottom: var(--space-xs);
1024
+ }
1025
+
1026
+ .breadcrumbs__list {
1027
+ align-items: center;
1028
+ display: flex;
1029
+ flex-wrap: wrap;
1030
+ font-size: var(--font-size-small);
1031
+ gap: 0;
1032
+ list-style: none;
1033
+ margin: 0;
1034
+ padding: 0;
1035
+ }
1036
+
1037
+ .breadcrumbs__item::before {
1038
+ color: var(--color-text-muted);
1039
+ content: "/";
1040
+ margin: 0 var(--space-xs);
1041
+ }
1042
+
1043
+ .breadcrumbs__item:first-child::before {
1044
+ content: none;
1045
+ margin: 0;
1046
+ }
1047
+
1048
+ .breadcrumbs__link {
1049
+ color: var(--color-primary);
1050
+ text-decoration: none;
1051
+ }
1052
+
1053
+ .breadcrumbs__link:hover {
1054
+ text-decoration: underline;
1055
+ }
1056
+
1057
+ .breadcrumbs__current {
1058
+ color: var(--color-text-muted);
1059
+ }
1060
+
1061
+ /* ==========================================================================
1062
+ View Switcher
1063
+ ========================================================================== */
1064
+
1065
+ .view-switcher {
1066
+ display: flex;
1067
+ gap: var(--space-xs);
1068
+ padding: var(--space-xs) 0;
1069
+ }
1070
+
1071
+ .view-switcher__button {
1072
+ align-items: center;
1073
+ border: 1px solid var(--color-border, #ddd);
1074
+ border-radius: var(--border-radius);
1075
+ color: var(--color-text-muted);
1076
+ display: flex;
1077
+ justify-content: center;
1078
+ padding: var(--space-xs);
1079
+ text-decoration: none;
1080
+ transition: background-color 0.2s ease, color 0.2s ease;
1081
+ }
1082
+
1083
+ .view-switcher__button:hover {
1084
+ background: var(--color-offset);
1085
+ color: var(--color-text);
1086
+ }
1087
+
1088
+ .view-switcher__button--active {
1089
+ background: var(--color-primary, #333);
1090
+ border-color: var(--color-primary, #333);
1091
+ color: #fff;
1092
+ }
1093
+
1094
+ .view-switcher__button--active:hover {
1095
+ background: var(--color-primary, #333);
1096
+ color: #fff;
1097
+ }
1098
+
1099
+ /* ==========================================================================
1100
+ Timeline View (all channels chronological)
1101
+ ========================================================================== */
1102
+
1103
+ .timeline-view {
1104
+ display: flex;
1105
+ flex-direction: column;
1106
+ gap: var(--space-m);
1107
+ }
1108
+
1109
+ .timeline-view__header {
1110
+ align-items: center;
1111
+ display: flex;
1112
+ flex-wrap: wrap;
1113
+ gap: var(--space-s);
1114
+ justify-content: space-between;
1115
+ }
1116
+
1117
+ .timeline-view__item {
1118
+ position: relative;
1119
+ }
1120
+
1121
+ .timeline-view__channel-badge {
1122
+ border-radius: 3px;
1123
+ color: #fff;
1124
+ display: inline-block;
1125
+ font-size: 0.6875rem;
1126
+ font-weight: 600;
1127
+ letter-spacing: 0.02em;
1128
+ line-height: 1;
1129
+ margin-bottom: var(--space-xs);
1130
+ padding: 3px 8px;
1131
+ text-transform: uppercase;
1132
+ }
1133
+
1134
+ .timeline-view__filter {
1135
+ position: relative;
1136
+ }
1137
+
1138
+ .timeline-view__filter-form {
1139
+ background: var(--color-background);
1140
+ border: 1px solid var(--color-border, #ddd);
1141
+ border-radius: var(--border-radius);
1142
+ display: flex;
1143
+ flex-direction: column;
1144
+ gap: var(--space-xs);
1145
+ min-width: 200px;
1146
+ padding: var(--space-s);
1147
+ position: absolute;
1148
+ right: 0;
1149
+ top: 100%;
1150
+ z-index: 10;
1151
+ }
1152
+
1153
+ .timeline-view__filter-label {
1154
+ align-items: center;
1155
+ cursor: pointer;
1156
+ display: flex;
1157
+ gap: var(--space-xs);
1158
+ }
1159
+
1160
+ .timeline-view__filter-color {
1161
+ border-radius: 2px;
1162
+ display: inline-block;
1163
+ height: 12px;
1164
+ width: 12px;
1165
+ }
1166
+
1167
+ /* ==========================================================================
1168
+ Compact Item Card (Deck view)
1169
+ ========================================================================== */
1170
+
1171
+ .item-card-compact {
1172
+ background: var(--color-background);
1173
+ border: 1px solid var(--color-border, #e0e0e0);
1174
+ border-radius: var(--border-radius);
1175
+ overflow: hidden;
1176
+ transition: background-color 0.2s ease;
1177
+ }
1178
+
1179
+ .item-card-compact:hover {
1180
+ background: var(--color-offset);
1181
+ }
1182
+
1183
+ .item-card-compact--read {
1184
+ opacity: 0.7;
1185
+ }
1186
+
1187
+ .item-card-compact:not(.item-card-compact--read) {
1188
+ border-left: 3px solid rgba(255, 204, 0, 0.8);
1189
+ }
1190
+
1191
+ .item-card-compact__link {
1192
+ color: inherit;
1193
+ display: flex;
1194
+ gap: var(--space-xs);
1195
+ padding: var(--space-xs) var(--space-s);
1196
+ text-decoration: none;
1197
+ }
1198
+
1199
+ .item-card-compact__photo {
1200
+ border-radius: var(--border-radius);
1201
+ flex-shrink: 0;
1202
+ height: 60px;
1203
+ object-fit: cover;
1204
+ width: 60px;
1205
+ }
1206
+
1207
+ .item-card-compact__body {
1208
+ flex: 1;
1209
+ min-width: 0;
1210
+ overflow: hidden;
1211
+ }
1212
+
1213
+ .item-card-compact__title {
1214
+ -webkit-box-orient: vertical;
1215
+ -webkit-line-clamp: 2;
1216
+ display: -webkit-box;
1217
+ font-size: 0.875rem;
1218
+ font-weight: 600;
1219
+ line-height: 1.3;
1220
+ margin: 0;
1221
+ overflow: hidden;
1222
+ }
1223
+
1224
+ .item-card-compact__text {
1225
+ -webkit-box-orient: vertical;
1226
+ -webkit-line-clamp: 2;
1227
+ color: var(--color-text-muted);
1228
+ display: -webkit-box;
1229
+ font-size: 0.8125rem;
1230
+ line-height: 1.4;
1231
+ margin: 0;
1232
+ overflow: hidden;
1233
+ }
1234
+
1235
+ .item-card-compact__meta {
1236
+ color: var(--color-text-muted);
1237
+ display: flex;
1238
+ font-size: 0.75rem;
1239
+ gap: var(--space-xs);
1240
+ margin-top: 2px;
1241
+ }
1242
+
1243
+ .item-card-compact__source {
1244
+ font-weight: 500;
1245
+ overflow: hidden;
1246
+ text-overflow: ellipsis;
1247
+ white-space: nowrap;
1248
+ }
1249
+
1250
+ .item-card-compact__date {
1251
+ flex-shrink: 0;
1252
+ white-space: nowrap;
1253
+ }
1254
+
1255
+ .item-card-compact__unread {
1256
+ color: rgba(255, 204, 0, 0.9);
1257
+ flex-shrink: 0;
1258
+ font-size: 0.625rem;
1259
+ }
1260
+
1261
+ /* ==========================================================================
1262
+ Deck View (TweetDeck-style columns)
1263
+ ========================================================================== */
1264
+
1265
+ .deck {
1266
+ display: flex;
1267
+ flex-direction: column;
1268
+ gap: var(--space-m);
1269
+ }
1270
+
1271
+ .deck__header {
1272
+ align-items: center;
1273
+ display: flex;
1274
+ flex-wrap: wrap;
1275
+ gap: var(--space-s);
1276
+ justify-content: space-between;
1277
+ }
1278
+
1279
+ .deck__columns {
1280
+ display: flex;
1281
+ gap: var(--space-m);
1282
+ overflow-x: auto;
1283
+ padding-bottom: var(--space-s);
1284
+ scroll-snap-type: x mandatory;
1285
+ }
1286
+
1287
+ .deck__column {
1288
+ flex-shrink: 0;
1289
+ scroll-snap-align: start;
1290
+ width: 320px;
1291
+ }
1292
+
1293
+ .deck__column-header {
1294
+ align-items: center;
1295
+ background: var(--color-offset);
1296
+ border-radius: var(--border-radius) var(--border-radius) 0 0;
1297
+ display: flex;
1298
+ gap: var(--space-s);
1299
+ justify-content: space-between;
1300
+ padding: var(--space-s) var(--space-m);
1301
+ position: sticky;
1302
+ top: 0;
1303
+ z-index: 1;
1304
+ }
1305
+
1306
+ .deck__column-name {
1307
+ color: inherit;
1308
+ font-weight: 600;
1309
+ text-decoration: none;
1310
+ }
1311
+
1312
+ .deck__column-items {
1313
+ display: flex;
1314
+ flex-direction: column;
1315
+ gap: var(--space-xs);
1316
+ max-height: 80vh;
1317
+ overflow-y: auto;
1318
+ padding: var(--space-xs);
1319
+ }
1320
+
1321
+ .deck__column-empty {
1322
+ color: var(--color-text-muted);
1323
+ font-size: 0.875rem;
1324
+ padding: var(--space-m);
1325
+ text-align: center;
1326
+ }
1327
+
1328
+ .deck__column-more {
1329
+ display: block;
1330
+ margin-top: var(--space-xs);
1331
+ text-align: center;
1332
+ }
1333
+
1334
+ /* Deck settings */
1335
+ .deck-settings__channels {
1336
+ display: flex;
1337
+ flex-direction: column;
1338
+ gap: var(--space-xs);
1339
+ margin: var(--space-m) 0;
1340
+ }
1341
+
1342
+ .deck-settings__channel {
1343
+ align-items: center;
1344
+ cursor: pointer;
1345
+ display: flex;
1346
+ gap: var(--space-xs);
1347
+ }
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,
@@ -36,6 +38,7 @@ import {
36
38
  validateExcludeRegex,
37
39
  } from "../utils/validation.js";
38
40
  import { proxyItemImages } from "../media/proxy.js";
41
+ import { getDeckConfig, saveDeckConfig } from "../storage/deck.js";
39
42
 
40
43
  /**
41
44
  * Reader index - redirect to channels
@@ -43,7 +46,10 @@ import { proxyItemImages } from "../media/proxy.js";
43
46
  * @param {object} response - Express response
44
47
  */
45
48
  export async function index(request, response) {
46
- response.redirect(`${request.baseUrl}/channels`);
49
+ const lastView = request.session?.microsubView || "timeline";
50
+ const validViews = ["channels", "deck", "timeline"];
51
+ const view = validViews.includes(lastView) ? lastView : "timeline";
52
+ response.redirect(`${request.baseUrl}/${view}`);
47
53
  }
48
54
 
49
55
  /**
@@ -57,10 +63,18 @@ export async function channels(request, response) {
57
63
 
58
64
  const channelList = await getChannels(application, userId);
59
65
 
66
+ if (request.session) request.session.microsubView = "channels";
67
+
60
68
  response.render("reader", {
61
- title: request.__("microsub.reader.title"),
69
+ title: request.__("microsub.views.channels"),
62
70
  channels: channelList,
63
71
  baseUrl: request.baseUrl,
72
+ readerBaseUrl: request.baseUrl,
73
+ activeView: "channels",
74
+ breadcrumbs: [
75
+ { text: "Reader", href: request.baseUrl },
76
+ { text: "Channels" },
77
+ ],
64
78
  });
65
79
  }
66
80
 
@@ -73,6 +87,13 @@ export async function newChannel(request, response) {
73
87
  response.render("channel-new", {
74
88
  title: request.__("microsub.channels.new"),
75
89
  baseUrl: request.baseUrl,
90
+ readerBaseUrl: request.baseUrl,
91
+ activeView: "channels",
92
+ breadcrumbs: [
93
+ { text: "Reader", href: request.baseUrl },
94
+ { text: "Channels", href: `${request.baseUrl}/channels` },
95
+ { text: request.__("microsub.channels.new") },
96
+ ],
76
97
  });
77
98
  }
78
99
 
@@ -143,6 +164,13 @@ export async function channel(request, response) {
143
164
  readCount,
144
165
  showRead: showReadItems,
145
166
  baseUrl: request.baseUrl,
167
+ readerBaseUrl: request.baseUrl,
168
+ activeView: "channels",
169
+ breadcrumbs: [
170
+ { text: "Reader", href: request.baseUrl },
171
+ { text: "Channels", href: `${request.baseUrl}/channels` },
172
+ { text: channelDocument.name },
173
+ ],
146
174
  });
147
175
  }
148
176
 
@@ -168,6 +196,14 @@ export async function settings(request, response) {
168
196
  }),
169
197
  channel: channelDocument,
170
198
  baseUrl: request.baseUrl,
199
+ readerBaseUrl: request.baseUrl,
200
+ activeView: "channels",
201
+ breadcrumbs: [
202
+ { text: "Reader", href: request.baseUrl },
203
+ { text: "Channels", href: `${request.baseUrl}/channels` },
204
+ { text: channelDocument.name, href: `${request.baseUrl}/channels/${uid}` },
205
+ { text: "Settings" },
206
+ ],
171
207
  });
172
208
  }
173
209
 
@@ -255,6 +291,14 @@ export async function feeds(request, response) {
255
291
  channel: channelDocument,
256
292
  feeds: feedList,
257
293
  baseUrl: request.baseUrl,
294
+ readerBaseUrl: request.baseUrl,
295
+ activeView: "channels",
296
+ breadcrumbs: [
297
+ { text: "Reader", href: request.baseUrl },
298
+ { text: "Channels", href: `${request.baseUrl}/channels` },
299
+ { text: channelDocument.name, href: `${request.baseUrl}/channels/${uid}` },
300
+ { text: "Feeds" },
301
+ ],
258
302
  });
259
303
  }
260
304
 
@@ -336,11 +380,25 @@ export async function item(request, response) {
336
380
  channel = await channelsCollection.findOne({ _id: itemDocument.channelId });
337
381
  }
338
382
 
383
+ const itemBreadcrumbs = [
384
+ { text: "Reader", href: request.baseUrl },
385
+ ];
386
+ if (channel) {
387
+ itemBreadcrumbs.push(
388
+ { text: "Channels", href: `${request.baseUrl}/channels` },
389
+ { text: channel.name, href: `${request.baseUrl}/channels/${channel.uid}` },
390
+ );
391
+ }
392
+ itemBreadcrumbs.push({ text: itemDocument.name || "Item" });
393
+
339
394
  response.render("item", {
340
395
  title: itemDocument.name || "Item",
341
396
  item: itemDocument,
342
397
  channel,
343
398
  baseUrl: request.baseUrl,
399
+ readerBaseUrl: request.baseUrl,
400
+ activeView: "channels",
401
+ breadcrumbs: itemBreadcrumbs,
344
402
  });
345
403
  }
346
404
 
@@ -451,6 +509,12 @@ export async function compose(request, response) {
451
509
  bookmarkOf: ensureString(bookmarkOf || bookmark),
452
510
  syndicationTargets,
453
511
  baseUrl: request.baseUrl,
512
+ readerBaseUrl: request.baseUrl,
513
+ activeView: "channels",
514
+ breadcrumbs: [
515
+ { text: "Reader", href: request.baseUrl },
516
+ { text: "Compose" },
517
+ ],
454
518
  });
455
519
  }
456
520
 
@@ -624,6 +688,12 @@ export async function searchPage(request, response) {
624
688
  title: request.__("microsub.search.title"),
625
689
  channels: channelList,
626
690
  baseUrl: request.baseUrl,
691
+ readerBaseUrl: request.baseUrl,
692
+ activeView: "channels",
693
+ breadcrumbs: [
694
+ { text: "Reader", href: request.baseUrl },
695
+ { text: "Search" },
696
+ ],
627
697
  });
628
698
  }
629
699
 
@@ -660,6 +730,12 @@ export async function searchFeeds(request, response) {
660
730
  discoveryError,
661
731
  searched: true,
662
732
  baseUrl: request.baseUrl,
733
+ readerBaseUrl: request.baseUrl,
734
+ activeView: "channels",
735
+ breadcrumbs: [
736
+ { text: "Reader", href: request.baseUrl },
737
+ { text: "Search" },
738
+ ],
663
739
  });
664
740
  }
665
741
 
@@ -691,6 +767,12 @@ export async function subscribe(request, response) {
691
767
  query: url,
692
768
  validationError: validation.error,
693
769
  baseUrl: request.baseUrl,
770
+ readerBaseUrl: request.baseUrl,
771
+ activeView: "channels",
772
+ breadcrumbs: [
773
+ { text: "Reader", href: request.baseUrl },
774
+ { text: "Search" },
775
+ ],
694
776
  });
695
777
  }
696
778
 
@@ -781,6 +863,15 @@ export async function editFeedForm(request, response) {
781
863
  channel: channelDocument,
782
864
  feed,
783
865
  baseUrl: request.baseUrl,
866
+ readerBaseUrl: request.baseUrl,
867
+ activeView: "channels",
868
+ breadcrumbs: [
869
+ { text: "Reader", href: request.baseUrl },
870
+ { text: "Channels", href: `${request.baseUrl}/channels` },
871
+ { text: channelDocument.name, href: `${request.baseUrl}/channels/${uid}` },
872
+ { text: "Feeds", href: `${request.baseUrl}/channels/${uid}/feeds` },
873
+ { text: "Edit" },
874
+ ],
784
875
  });
785
876
  }
786
877
 
@@ -816,6 +907,15 @@ export async function updateFeedUrl(request, response) {
816
907
  feed,
817
908
  error: validation.error,
818
909
  baseUrl: request.baseUrl,
910
+ readerBaseUrl: request.baseUrl,
911
+ activeView: "channels",
912
+ breadcrumbs: [
913
+ { text: "Reader", href: request.baseUrl },
914
+ { text: "Channels", href: `${request.baseUrl}/channels` },
915
+ { text: channelDocument.name, href: `${request.baseUrl}/channels/${uid}` },
916
+ { text: "Feeds", href: `${request.baseUrl}/channels/${uid}/feeds` },
917
+ { text: "Edit" },
918
+ ],
819
919
  });
820
920
  }
821
921
 
@@ -995,6 +1095,12 @@ export async function actorProfile(request, response) {
995
1095
  isFollowing,
996
1096
  canFollow,
997
1097
  baseUrl: request.baseUrl,
1098
+ readerBaseUrl: request.baseUrl,
1099
+ activeView: "channels",
1100
+ breadcrumbs: [
1101
+ { text: "Reader", href: request.baseUrl },
1102
+ { text: actor.name || "Actor" },
1103
+ ],
998
1104
  });
999
1105
  } catch (error) {
1000
1106
  console.error(`[Microsub] Actor profile fetch failed: ${error.message}`);
@@ -1006,7 +1112,13 @@ export async function actorProfile(request, response) {
1006
1112
  isFollowing,
1007
1113
  canFollow,
1008
1114
  baseUrl: request.baseUrl,
1115
+ readerBaseUrl: request.baseUrl,
1116
+ activeView: "channels",
1009
1117
  error: "Could not fetch this actor's profile. They may have restricted access.",
1118
+ breadcrumbs: [
1119
+ { text: "Reader", href: request.baseUrl },
1120
+ { text: "Actor" },
1121
+ ],
1010
1122
  });
1011
1123
  }
1012
1124
  }
@@ -1059,6 +1171,194 @@ export async function unfollowActorAction(request, response) {
1059
1171
  );
1060
1172
  }
1061
1173
 
1174
+ /**
1175
+ * Timeline view - all channels chronologically
1176
+ * @param {object} request - Express request
1177
+ * @param {object} response - Express response
1178
+ */
1179
+ export async function timeline(request, response) {
1180
+ const { application } = request.app.locals;
1181
+ const userId = getUserId(request);
1182
+ const { before, after } = request.query;
1183
+
1184
+ // Get channels with colors for filtering UI and item decoration
1185
+ const channelList = await getChannelsWithColors(application, userId);
1186
+
1187
+ // Build channel lookup map (ObjectId string -> { name, color })
1188
+ const channelMap = new Map();
1189
+ for (const ch of channelList) {
1190
+ channelMap.set(ch._id.toString(), { name: ch.name, color: ch.color });
1191
+ }
1192
+
1193
+ // Parse excluded channel IDs from query params
1194
+ const excludeParam = request.query.exclude;
1195
+ const excludeIds = excludeParam
1196
+ ? (Array.isArray(excludeParam) ? excludeParam : [excludeParam])
1197
+ : [];
1198
+
1199
+ // Exclude the notifications channel by default
1200
+ const notificationsChannel = channelList.find((ch) => ch.uid === "notifications");
1201
+ const excludeChannelIds = [...excludeIds];
1202
+ if (notificationsChannel && !excludeChannelIds.includes(notificationsChannel._id.toString())) {
1203
+ excludeChannelIds.push(notificationsChannel._id.toString());
1204
+ }
1205
+
1206
+ const result = await getAllTimelineItems(application, {
1207
+ before,
1208
+ after,
1209
+ userId,
1210
+ excludeChannelIds,
1211
+ });
1212
+
1213
+ // Proxy images
1214
+ const proxyBaseUrl = application.url;
1215
+ if (proxyBaseUrl && result.items) {
1216
+ result.items = result.items.map((item) => proxyItemImages(item, proxyBaseUrl));
1217
+ }
1218
+
1219
+ // Decorate items with channel name and color
1220
+ for (const item of result.items) {
1221
+ if (item._channelId) {
1222
+ const info = channelMap.get(item._channelId);
1223
+ if (info) {
1224
+ item._channelName = info.name;
1225
+ item._channelColor = info.color;
1226
+ }
1227
+ }
1228
+ }
1229
+
1230
+ // Set view preference cookie
1231
+ if (request.session) request.session.microsubView = "timeline";
1232
+
1233
+ response.render("timeline", {
1234
+ title: "Timeline",
1235
+ channels: channelList,
1236
+ items: result.items,
1237
+ paging: result.paging,
1238
+ excludeIds,
1239
+ baseUrl: request.baseUrl,
1240
+ readerBaseUrl: request.baseUrl,
1241
+ activeView: "timeline",
1242
+ breadcrumbs: [
1243
+ { text: "Reader", href: request.baseUrl },
1244
+ { text: "Timeline" },
1245
+ ],
1246
+ });
1247
+ }
1248
+
1249
+ /**
1250
+ * Deck view - TweetDeck-style columns
1251
+ * @param {object} request - Express request
1252
+ * @param {object} response - Express response
1253
+ */
1254
+ export async function deck(request, response) {
1255
+ const { application } = request.app.locals;
1256
+ const userId = getUserId(request);
1257
+
1258
+ const channelList = await getChannelsWithColors(application, userId);
1259
+ const deckConfig = await getDeckConfig(application, userId);
1260
+
1261
+ // Determine which channels to show as columns
1262
+ let columnChannels;
1263
+ if (deckConfig?.columns?.length > 0) {
1264
+ // Use saved config order
1265
+ const channelMap = new Map(channelList.map((ch) => [ch._id.toString(), ch]));
1266
+ columnChannels = deckConfig.columns
1267
+ .map((col) => channelMap.get(col.channelId.toString()))
1268
+ .filter(Boolean);
1269
+ } else {
1270
+ // Default: all channels except notifications
1271
+ columnChannels = channelList.filter((ch) => ch.uid !== "notifications");
1272
+ }
1273
+
1274
+ // Fetch items for each column (limited to 10 per column for performance)
1275
+ const proxyBaseUrl = application.url;
1276
+ const columns = await Promise.all(
1277
+ columnChannels.map(async (channel) => {
1278
+ const result = await getTimelineItems(application, channel._id, {
1279
+ userId,
1280
+ limit: 10,
1281
+ });
1282
+
1283
+ if (proxyBaseUrl && result.items) {
1284
+ result.items = result.items.map((item) =>
1285
+ proxyItemImages(item, proxyBaseUrl),
1286
+ );
1287
+ }
1288
+
1289
+ return {
1290
+ channel,
1291
+ items: result.items,
1292
+ paging: result.paging,
1293
+ };
1294
+ }),
1295
+ );
1296
+
1297
+ // Set view preference cookie
1298
+ if (request.session) request.session.microsubView = "deck";
1299
+
1300
+ response.render("deck", {
1301
+ title: "Deck",
1302
+ columns,
1303
+ baseUrl: request.baseUrl,
1304
+ readerBaseUrl: request.baseUrl,
1305
+ activeView: "deck",
1306
+ breadcrumbs: [
1307
+ { text: "Reader", href: request.baseUrl },
1308
+ { text: "Deck" },
1309
+ ],
1310
+ });
1311
+ }
1312
+
1313
+ /**
1314
+ * Deck settings page
1315
+ * @param {object} request - Express request
1316
+ * @param {object} response - Express response
1317
+ */
1318
+ export async function deckSettings(request, response) {
1319
+ const { application } = request.app.locals;
1320
+ const userId = getUserId(request);
1321
+
1322
+ const channelList = await getChannelsWithColors(application, userId);
1323
+ const deckConfig = await getDeckConfig(application, userId);
1324
+
1325
+ const selectedIds = deckConfig?.columns
1326
+ ? deckConfig.columns.map((col) => col.channelId.toString())
1327
+ : channelList.filter((ch) => ch.uid !== "notifications").map((ch) => ch._id.toString());
1328
+
1329
+ response.render("deck-settings", {
1330
+ title: "Deck settings",
1331
+ channels: channelList,
1332
+ selectedIds,
1333
+ baseUrl: request.baseUrl,
1334
+ readerBaseUrl: request.baseUrl,
1335
+ activeView: "deck",
1336
+ breadcrumbs: [
1337
+ { text: "Reader", href: request.baseUrl },
1338
+ { text: "Deck", href: `${request.baseUrl}/deck` },
1339
+ { text: "Settings" },
1340
+ ],
1341
+ });
1342
+ }
1343
+
1344
+ /**
1345
+ * Save deck settings
1346
+ * @param {object} request - Express request
1347
+ * @param {object} response - Express response
1348
+ */
1349
+ export async function saveDeckSettings(request, response) {
1350
+ const { application } = request.app.locals;
1351
+ const userId = getUserId(request);
1352
+
1353
+ let { columns } = request.body;
1354
+ if (!columns) columns = [];
1355
+ if (!Array.isArray(columns)) columns = [columns];
1356
+
1357
+ await saveDeckConfig(application, userId, columns);
1358
+
1359
+ response.redirect(`${request.baseUrl}/deck`);
1360
+ }
1361
+
1062
1362
  export const readerController = {
1063
1363
  index,
1064
1364
  channels,
@@ -1086,4 +1386,8 @@ export const readerController = {
1086
1386
  actorProfile,
1087
1387
  followActorAction,
1088
1388
  unfollowActorAction,
1389
+ timeline,
1390
+ deck,
1391
+ deckSettings,
1392
+ saveDeckSettings,
1089
1393
  };
@@ -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.37",
3
+ "version": "1.0.39",
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
@@ -3,9 +3,7 @@
3
3
  {% block reader %}
4
4
  <div class="channel">
5
5
  <header class="channel__header">
6
- <a href="{{ baseUrl }}/channels" class="back-link">
7
- {{ icon("previous") }} {{ __("microsub.channels.title") }}
8
- </a>
6
+ <h1>{{ channel.name }}</h1>
9
7
  <div class="channel__actions">
10
8
  {% if not showRead and items.length > 0 %}
11
9
  <form action="{{ baseUrl }}/api/mark-read" method="POST" style="display: inline;">
@@ -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,7 @@
6
6
 
7
7
  {% block content %}
8
8
  <link rel="stylesheet" href="/assets/@rmdes-indiekit-endpoint-microsub/styles.css">
9
+ {% include "partials/breadcrumbs.njk" %}
10
+ {% include "partials/view-switcher.njk" %}
9
11
  {% block reader %}{% endblock %}
10
12
  {% endblock %}
@@ -0,0 +1,16 @@
1
+ {# Breadcrumb navigation #}
2
+ {% if breadcrumbs and breadcrumbs.length > 0 %}
3
+ <nav class="breadcrumbs" aria-label="Breadcrumb">
4
+ <ol class="breadcrumbs__list">
5
+ {% for crumb in breadcrumbs %}
6
+ <li class="breadcrumbs__item">
7
+ {% if crumb.href %}
8
+ <a href="{{ crumb.href }}" class="breadcrumbs__link">{{ crumb.text }}</a>
9
+ {% else %}
10
+ <span class="breadcrumbs__current" aria-current="page">{{ crumb.text }}</span>
11
+ {% endif %}
12
+ </li>
13
+ {% endfor %}
14
+ </ol>
15
+ </nav>
16
+ {% endif %}
@@ -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>
@@ -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">
35
+ {% if item._channelName %}
36
+ <span class="timeline-view__channel-badge" style="background: {{ item._channelColor or '#888' }}">
37
+ {{ item._channelName }}
38
+ </span>
39
+ {% endif %}
40
+ {% include "partials/item-card.njk" %}
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 %}