@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 +331 -0
- package/index.js +5 -0
- package/lib/controllers/reader.js +306 -2
- package/lib/storage/channels.js +79 -0
- package/lib/storage/deck.js +56 -0
- package/lib/storage/items.js +64 -0
- package/locales/en.json +5 -0
- package/package.json +1 -1
- package/views/channel.njk +1 -3
- package/views/deck-settings.njk +33 -0
- package/views/deck.njk +52 -0
- package/views/layouts/reader.njk +2 -0
- package/views/partials/breadcrumbs.njk +16 -0
- package/views/partials/item-card-compact.njk +37 -0
- package/views/partials/view-switcher.njk +25 -0
- package/views/timeline.njk +100 -0
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
|
-
|
|
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.
|
|
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
|
};
|
package/lib/storage/channels.js
CHANGED
|
@@ -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
|
+
}
|
package/lib/storage/items.js
CHANGED
|
@@ -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
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
|
-
<
|
|
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 %}
|
package/views/layouts/reader.njk
CHANGED
|
@@ -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 %}
|