@rmdes/indiekit-endpoint-microsub 1.0.37 → 1.0.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/styles.css +287 -0
- package/index.js +5 -0
- package/lib/controllers/reader.js +217 -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/deck-settings.njk +33 -0
- package/views/deck.njk +52 -0
- package/views/layouts/reader.njk +1 -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,290 @@
|
|
|
1014
1014
|
background: #7c3aed20;
|
|
1015
1015
|
color: #7c3aed;
|
|
1016
1016
|
}
|
|
1017
|
+
|
|
1018
|
+
/* ==========================================================================
|
|
1019
|
+
View Switcher
|
|
1020
|
+
========================================================================== */
|
|
1021
|
+
|
|
1022
|
+
.view-switcher {
|
|
1023
|
+
display: flex;
|
|
1024
|
+
gap: var(--space-xs);
|
|
1025
|
+
padding: var(--space-xs) 0;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
.view-switcher__button {
|
|
1029
|
+
align-items: center;
|
|
1030
|
+
border: 1px solid var(--color-border, #ddd);
|
|
1031
|
+
border-radius: var(--border-radius);
|
|
1032
|
+
color: var(--color-text-muted);
|
|
1033
|
+
display: flex;
|
|
1034
|
+
justify-content: center;
|
|
1035
|
+
padding: var(--space-xs);
|
|
1036
|
+
text-decoration: none;
|
|
1037
|
+
transition: background-color 0.2s ease, color 0.2s ease;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
.view-switcher__button:hover {
|
|
1041
|
+
background: var(--color-offset);
|
|
1042
|
+
color: var(--color-text);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
.view-switcher__button--active {
|
|
1046
|
+
background: var(--color-primary, #333);
|
|
1047
|
+
border-color: var(--color-primary, #333);
|
|
1048
|
+
color: #fff;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
.view-switcher__button--active:hover {
|
|
1052
|
+
background: var(--color-primary, #333);
|
|
1053
|
+
color: #fff;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/* ==========================================================================
|
|
1057
|
+
Timeline View (all channels chronological)
|
|
1058
|
+
========================================================================== */
|
|
1059
|
+
|
|
1060
|
+
.timeline-view {
|
|
1061
|
+
display: flex;
|
|
1062
|
+
flex-direction: column;
|
|
1063
|
+
gap: var(--space-m);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
.timeline-view__header {
|
|
1067
|
+
align-items: center;
|
|
1068
|
+
display: flex;
|
|
1069
|
+
flex-wrap: wrap;
|
|
1070
|
+
gap: var(--space-s);
|
|
1071
|
+
justify-content: space-between;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
.timeline-view__item {
|
|
1075
|
+
border-radius: var(--border-radius);
|
|
1076
|
+
position: relative;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
.timeline-view__item .item-card {
|
|
1080
|
+
border-left: none;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
.timeline-view__channel-label {
|
|
1084
|
+
display: block;
|
|
1085
|
+
font-size: 0.75rem;
|
|
1086
|
+
font-weight: 600;
|
|
1087
|
+
padding: 0 var(--space-s) var(--space-xs);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
.timeline-view__filter {
|
|
1091
|
+
position: relative;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
.timeline-view__filter-form {
|
|
1095
|
+
background: var(--color-background);
|
|
1096
|
+
border: 1px solid var(--color-border, #ddd);
|
|
1097
|
+
border-radius: var(--border-radius);
|
|
1098
|
+
display: flex;
|
|
1099
|
+
flex-direction: column;
|
|
1100
|
+
gap: var(--space-xs);
|
|
1101
|
+
min-width: 200px;
|
|
1102
|
+
padding: var(--space-s);
|
|
1103
|
+
position: absolute;
|
|
1104
|
+
right: 0;
|
|
1105
|
+
top: 100%;
|
|
1106
|
+
z-index: 10;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
.timeline-view__filter-label {
|
|
1110
|
+
align-items: center;
|
|
1111
|
+
cursor: pointer;
|
|
1112
|
+
display: flex;
|
|
1113
|
+
gap: var(--space-xs);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
.timeline-view__filter-color {
|
|
1117
|
+
border-radius: 2px;
|
|
1118
|
+
display: inline-block;
|
|
1119
|
+
height: 12px;
|
|
1120
|
+
width: 12px;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/* ==========================================================================
|
|
1124
|
+
Compact Item Card (Deck view)
|
|
1125
|
+
========================================================================== */
|
|
1126
|
+
|
|
1127
|
+
.item-card-compact {
|
|
1128
|
+
background: var(--color-background);
|
|
1129
|
+
border: 1px solid var(--color-border, #e0e0e0);
|
|
1130
|
+
border-radius: var(--border-radius);
|
|
1131
|
+
overflow: hidden;
|
|
1132
|
+
transition: background-color 0.2s ease;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
.item-card-compact:hover {
|
|
1136
|
+
background: var(--color-offset);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
.item-card-compact--read {
|
|
1140
|
+
opacity: 0.7;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
.item-card-compact:not(.item-card-compact--read) {
|
|
1144
|
+
border-left: 3px solid rgba(255, 204, 0, 0.8);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
.item-card-compact__link {
|
|
1148
|
+
color: inherit;
|
|
1149
|
+
display: flex;
|
|
1150
|
+
gap: var(--space-xs);
|
|
1151
|
+
padding: var(--space-xs) var(--space-s);
|
|
1152
|
+
text-decoration: none;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
.item-card-compact__photo {
|
|
1156
|
+
border-radius: var(--border-radius);
|
|
1157
|
+
flex-shrink: 0;
|
|
1158
|
+
height: 60px;
|
|
1159
|
+
object-fit: cover;
|
|
1160
|
+
width: 60px;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
.item-card-compact__body {
|
|
1164
|
+
flex: 1;
|
|
1165
|
+
min-width: 0;
|
|
1166
|
+
overflow: hidden;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
.item-card-compact__title {
|
|
1170
|
+
-webkit-box-orient: vertical;
|
|
1171
|
+
-webkit-line-clamp: 2;
|
|
1172
|
+
display: -webkit-box;
|
|
1173
|
+
font-size: 0.875rem;
|
|
1174
|
+
font-weight: 600;
|
|
1175
|
+
line-height: 1.3;
|
|
1176
|
+
margin: 0;
|
|
1177
|
+
overflow: hidden;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
.item-card-compact__text {
|
|
1181
|
+
-webkit-box-orient: vertical;
|
|
1182
|
+
-webkit-line-clamp: 2;
|
|
1183
|
+
color: var(--color-text-muted);
|
|
1184
|
+
display: -webkit-box;
|
|
1185
|
+
font-size: 0.8125rem;
|
|
1186
|
+
line-height: 1.4;
|
|
1187
|
+
margin: 0;
|
|
1188
|
+
overflow: hidden;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
.item-card-compact__meta {
|
|
1192
|
+
color: var(--color-text-muted);
|
|
1193
|
+
display: flex;
|
|
1194
|
+
font-size: 0.75rem;
|
|
1195
|
+
gap: var(--space-xs);
|
|
1196
|
+
margin-top: 2px;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
.item-card-compact__source {
|
|
1200
|
+
font-weight: 500;
|
|
1201
|
+
overflow: hidden;
|
|
1202
|
+
text-overflow: ellipsis;
|
|
1203
|
+
white-space: nowrap;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
.item-card-compact__date {
|
|
1207
|
+
flex-shrink: 0;
|
|
1208
|
+
white-space: nowrap;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
.item-card-compact__unread {
|
|
1212
|
+
color: rgba(255, 204, 0, 0.9);
|
|
1213
|
+
flex-shrink: 0;
|
|
1214
|
+
font-size: 0.625rem;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/* ==========================================================================
|
|
1218
|
+
Deck View (TweetDeck-style columns)
|
|
1219
|
+
========================================================================== */
|
|
1220
|
+
|
|
1221
|
+
.deck {
|
|
1222
|
+
display: flex;
|
|
1223
|
+
flex-direction: column;
|
|
1224
|
+
gap: var(--space-m);
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
.deck__header {
|
|
1228
|
+
align-items: center;
|
|
1229
|
+
display: flex;
|
|
1230
|
+
flex-wrap: wrap;
|
|
1231
|
+
gap: var(--space-s);
|
|
1232
|
+
justify-content: space-between;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
.deck__columns {
|
|
1236
|
+
display: flex;
|
|
1237
|
+
gap: var(--space-m);
|
|
1238
|
+
overflow-x: auto;
|
|
1239
|
+
padding-bottom: var(--space-s);
|
|
1240
|
+
scroll-snap-type: x mandatory;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
.deck__column {
|
|
1244
|
+
flex-shrink: 0;
|
|
1245
|
+
scroll-snap-align: start;
|
|
1246
|
+
width: 320px;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
.deck__column-header {
|
|
1250
|
+
align-items: center;
|
|
1251
|
+
background: var(--color-offset);
|
|
1252
|
+
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
|
1253
|
+
display: flex;
|
|
1254
|
+
gap: var(--space-s);
|
|
1255
|
+
justify-content: space-between;
|
|
1256
|
+
padding: var(--space-s) var(--space-m);
|
|
1257
|
+
position: sticky;
|
|
1258
|
+
top: 0;
|
|
1259
|
+
z-index: 1;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
.deck__column-name {
|
|
1263
|
+
color: inherit;
|
|
1264
|
+
font-weight: 600;
|
|
1265
|
+
text-decoration: none;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
.deck__column-items {
|
|
1269
|
+
display: flex;
|
|
1270
|
+
flex-direction: column;
|
|
1271
|
+
gap: var(--space-xs);
|
|
1272
|
+
max-height: 80vh;
|
|
1273
|
+
overflow-y: auto;
|
|
1274
|
+
padding: var(--space-xs);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
.deck__column-empty {
|
|
1278
|
+
color: var(--color-text-muted);
|
|
1279
|
+
font-size: 0.875rem;
|
|
1280
|
+
padding: var(--space-m);
|
|
1281
|
+
text-align: center;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
.deck__column-more {
|
|
1285
|
+
display: block;
|
|
1286
|
+
margin-top: var(--space-xs);
|
|
1287
|
+
text-align: center;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
/* Deck settings */
|
|
1291
|
+
.deck-settings__channels {
|
|
1292
|
+
display: flex;
|
|
1293
|
+
flex-direction: column;
|
|
1294
|
+
gap: var(--space-xs);
|
|
1295
|
+
margin: var(--space-m) 0;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
.deck-settings__channel {
|
|
1299
|
+
align-items: center;
|
|
1300
|
+
cursor: pointer;
|
|
1301
|
+
display: flex;
|
|
1302
|
+
gap: var(--space-xs);
|
|
1303
|
+
}
|
package/index.js
CHANGED
|
@@ -132,6 +132,10 @@ export default class MicrosubEndpoint {
|
|
|
132
132
|
readerRouter.post("/actor/unfollow", readerController.unfollowActorAction);
|
|
133
133
|
readerRouter.post("/api/mark-read", readerController.markAllRead);
|
|
134
134
|
readerRouter.get("/opml", opmlController.exportOpml);
|
|
135
|
+
readerRouter.get("/timeline", readerController.timeline);
|
|
136
|
+
readerRouter.get("/deck", readerController.deck);
|
|
137
|
+
readerRouter.get("/deck/settings", readerController.deckSettings);
|
|
138
|
+
readerRouter.post("/deck/settings", readerController.saveDeckSettings);
|
|
135
139
|
router.use("/reader", readerRouter);
|
|
136
140
|
|
|
137
141
|
return router;
|
|
@@ -171,6 +175,7 @@ export default class MicrosubEndpoint {
|
|
|
171
175
|
indiekit.addCollection("microsub_notifications");
|
|
172
176
|
indiekit.addCollection("microsub_muted");
|
|
173
177
|
indiekit.addCollection("microsub_blocked");
|
|
178
|
+
indiekit.addCollection("microsub_deck_config");
|
|
174
179
|
|
|
175
180
|
console.info("[Microsub] Registered MongoDB collections");
|
|
176
181
|
|
|
@@ -9,6 +9,7 @@ import { ObjectId } from "mongodb";
|
|
|
9
9
|
import { refreshFeedNow } from "../polling/scheduler.js";
|
|
10
10
|
import {
|
|
11
11
|
getChannels,
|
|
12
|
+
getChannelsWithColors,
|
|
12
13
|
getChannel,
|
|
13
14
|
createChannel,
|
|
14
15
|
updateChannelSettings,
|
|
@@ -24,6 +25,7 @@ import {
|
|
|
24
25
|
} from "../storage/feeds.js";
|
|
25
26
|
import {
|
|
26
27
|
getTimelineItems,
|
|
28
|
+
getAllTimelineItems,
|
|
27
29
|
getItemById,
|
|
28
30
|
markItemsRead,
|
|
29
31
|
countReadItems,
|
|
@@ -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 || "channels";
|
|
50
|
+
const validViews = ["channels", "deck", "timeline"];
|
|
51
|
+
const view = validViews.includes(lastView) ? lastView : "channels";
|
|
52
|
+
response.redirect(`${request.baseUrl}/${view}`);
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
/**
|
|
@@ -57,10 +63,14 @@ 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",
|
|
64
74
|
});
|
|
65
75
|
}
|
|
66
76
|
|
|
@@ -73,6 +83,8 @@ export async function newChannel(request, response) {
|
|
|
73
83
|
response.render("channel-new", {
|
|
74
84
|
title: request.__("microsub.channels.new"),
|
|
75
85
|
baseUrl: request.baseUrl,
|
|
86
|
+
readerBaseUrl: request.baseUrl,
|
|
87
|
+
activeView: "channels",
|
|
76
88
|
});
|
|
77
89
|
}
|
|
78
90
|
|
|
@@ -143,6 +155,8 @@ export async function channel(request, response) {
|
|
|
143
155
|
readCount,
|
|
144
156
|
showRead: showReadItems,
|
|
145
157
|
baseUrl: request.baseUrl,
|
|
158
|
+
readerBaseUrl: request.baseUrl,
|
|
159
|
+
activeView: "channels",
|
|
146
160
|
});
|
|
147
161
|
}
|
|
148
162
|
|
|
@@ -168,6 +182,8 @@ export async function settings(request, response) {
|
|
|
168
182
|
}),
|
|
169
183
|
channel: channelDocument,
|
|
170
184
|
baseUrl: request.baseUrl,
|
|
185
|
+
readerBaseUrl: request.baseUrl,
|
|
186
|
+
activeView: "channels",
|
|
171
187
|
});
|
|
172
188
|
}
|
|
173
189
|
|
|
@@ -255,6 +271,8 @@ export async function feeds(request, response) {
|
|
|
255
271
|
channel: channelDocument,
|
|
256
272
|
feeds: feedList,
|
|
257
273
|
baseUrl: request.baseUrl,
|
|
274
|
+
readerBaseUrl: request.baseUrl,
|
|
275
|
+
activeView: "channels",
|
|
258
276
|
});
|
|
259
277
|
}
|
|
260
278
|
|
|
@@ -341,6 +359,8 @@ export async function item(request, response) {
|
|
|
341
359
|
item: itemDocument,
|
|
342
360
|
channel,
|
|
343
361
|
baseUrl: request.baseUrl,
|
|
362
|
+
readerBaseUrl: request.baseUrl,
|
|
363
|
+
activeView: "channels",
|
|
344
364
|
});
|
|
345
365
|
}
|
|
346
366
|
|
|
@@ -451,6 +471,8 @@ export async function compose(request, response) {
|
|
|
451
471
|
bookmarkOf: ensureString(bookmarkOf || bookmark),
|
|
452
472
|
syndicationTargets,
|
|
453
473
|
baseUrl: request.baseUrl,
|
|
474
|
+
readerBaseUrl: request.baseUrl,
|
|
475
|
+
activeView: "channels",
|
|
454
476
|
});
|
|
455
477
|
}
|
|
456
478
|
|
|
@@ -624,6 +646,8 @@ export async function searchPage(request, response) {
|
|
|
624
646
|
title: request.__("microsub.search.title"),
|
|
625
647
|
channels: channelList,
|
|
626
648
|
baseUrl: request.baseUrl,
|
|
649
|
+
readerBaseUrl: request.baseUrl,
|
|
650
|
+
activeView: "channels",
|
|
627
651
|
});
|
|
628
652
|
}
|
|
629
653
|
|
|
@@ -660,6 +684,8 @@ export async function searchFeeds(request, response) {
|
|
|
660
684
|
discoveryError,
|
|
661
685
|
searched: true,
|
|
662
686
|
baseUrl: request.baseUrl,
|
|
687
|
+
readerBaseUrl: request.baseUrl,
|
|
688
|
+
activeView: "channels",
|
|
663
689
|
});
|
|
664
690
|
}
|
|
665
691
|
|
|
@@ -691,6 +717,8 @@ export async function subscribe(request, response) {
|
|
|
691
717
|
query: url,
|
|
692
718
|
validationError: validation.error,
|
|
693
719
|
baseUrl: request.baseUrl,
|
|
720
|
+
readerBaseUrl: request.baseUrl,
|
|
721
|
+
activeView: "channels",
|
|
694
722
|
});
|
|
695
723
|
}
|
|
696
724
|
|
|
@@ -781,6 +809,8 @@ export async function editFeedForm(request, response) {
|
|
|
781
809
|
channel: channelDocument,
|
|
782
810
|
feed,
|
|
783
811
|
baseUrl: request.baseUrl,
|
|
812
|
+
readerBaseUrl: request.baseUrl,
|
|
813
|
+
activeView: "channels",
|
|
784
814
|
});
|
|
785
815
|
}
|
|
786
816
|
|
|
@@ -816,6 +846,8 @@ export async function updateFeedUrl(request, response) {
|
|
|
816
846
|
feed,
|
|
817
847
|
error: validation.error,
|
|
818
848
|
baseUrl: request.baseUrl,
|
|
849
|
+
readerBaseUrl: request.baseUrl,
|
|
850
|
+
activeView: "channels",
|
|
819
851
|
});
|
|
820
852
|
}
|
|
821
853
|
|
|
@@ -995,6 +1027,8 @@ export async function actorProfile(request, response) {
|
|
|
995
1027
|
isFollowing,
|
|
996
1028
|
canFollow,
|
|
997
1029
|
baseUrl: request.baseUrl,
|
|
1030
|
+
readerBaseUrl: request.baseUrl,
|
|
1031
|
+
activeView: "channels",
|
|
998
1032
|
});
|
|
999
1033
|
} catch (error) {
|
|
1000
1034
|
console.error(`[Microsub] Actor profile fetch failed: ${error.message}`);
|
|
@@ -1006,6 +1040,8 @@ export async function actorProfile(request, response) {
|
|
|
1006
1040
|
isFollowing,
|
|
1007
1041
|
canFollow,
|
|
1008
1042
|
baseUrl: request.baseUrl,
|
|
1043
|
+
readerBaseUrl: request.baseUrl,
|
|
1044
|
+
activeView: "channels",
|
|
1009
1045
|
error: "Could not fetch this actor's profile. They may have restricted access.",
|
|
1010
1046
|
});
|
|
1011
1047
|
}
|
|
@@ -1059,6 +1095,181 @@ export async function unfollowActorAction(request, response) {
|
|
|
1059
1095
|
);
|
|
1060
1096
|
}
|
|
1061
1097
|
|
|
1098
|
+
/**
|
|
1099
|
+
* Timeline view - all channels chronologically
|
|
1100
|
+
* @param {object} request - Express request
|
|
1101
|
+
* @param {object} response - Express response
|
|
1102
|
+
*/
|
|
1103
|
+
export async function timeline(request, response) {
|
|
1104
|
+
const { application } = request.app.locals;
|
|
1105
|
+
const userId = getUserId(request);
|
|
1106
|
+
const { before, after } = request.query;
|
|
1107
|
+
|
|
1108
|
+
// Get channels with colors for filtering UI and item decoration
|
|
1109
|
+
const channelList = await getChannelsWithColors(application, userId);
|
|
1110
|
+
|
|
1111
|
+
// Build channel lookup map (ObjectId string -> { name, color })
|
|
1112
|
+
const channelMap = new Map();
|
|
1113
|
+
for (const ch of channelList) {
|
|
1114
|
+
channelMap.set(ch._id.toString(), { name: ch.name, color: ch.color });
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// Parse excluded channel IDs from query params
|
|
1118
|
+
const excludeParam = request.query.exclude;
|
|
1119
|
+
const excludeIds = excludeParam
|
|
1120
|
+
? (Array.isArray(excludeParam) ? excludeParam : [excludeParam])
|
|
1121
|
+
: [];
|
|
1122
|
+
|
|
1123
|
+
// Exclude the notifications channel by default
|
|
1124
|
+
const notificationsChannel = channelList.find((ch) => ch.uid === "notifications");
|
|
1125
|
+
const excludeChannelIds = [...excludeIds];
|
|
1126
|
+
if (notificationsChannel && !excludeChannelIds.includes(notificationsChannel._id.toString())) {
|
|
1127
|
+
excludeChannelIds.push(notificationsChannel._id.toString());
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
const result = await getAllTimelineItems(application, {
|
|
1131
|
+
before,
|
|
1132
|
+
after,
|
|
1133
|
+
userId,
|
|
1134
|
+
excludeChannelIds,
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
// Proxy images
|
|
1138
|
+
const proxyBaseUrl = application.url;
|
|
1139
|
+
if (proxyBaseUrl && result.items) {
|
|
1140
|
+
result.items = result.items.map((item) => proxyItemImages(item, proxyBaseUrl));
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// Decorate items with channel name and color
|
|
1144
|
+
for (const item of result.items) {
|
|
1145
|
+
if (item._channelId) {
|
|
1146
|
+
const info = channelMap.get(item._channelId);
|
|
1147
|
+
if (info) {
|
|
1148
|
+
item._channelName = info.name;
|
|
1149
|
+
item._channelColor = info.color;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// Set view preference cookie
|
|
1155
|
+
if (request.session) request.session.microsubView = "timeline";
|
|
1156
|
+
|
|
1157
|
+
response.render("timeline", {
|
|
1158
|
+
title: "Timeline",
|
|
1159
|
+
channels: channelList,
|
|
1160
|
+
items: result.items,
|
|
1161
|
+
paging: result.paging,
|
|
1162
|
+
excludeIds,
|
|
1163
|
+
baseUrl: request.baseUrl,
|
|
1164
|
+
readerBaseUrl: request.baseUrl,
|
|
1165
|
+
activeView: "timeline",
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/**
|
|
1170
|
+
* Deck view - TweetDeck-style columns
|
|
1171
|
+
* @param {object} request - Express request
|
|
1172
|
+
* @param {object} response - Express response
|
|
1173
|
+
*/
|
|
1174
|
+
export async function deck(request, response) {
|
|
1175
|
+
const { application } = request.app.locals;
|
|
1176
|
+
const userId = getUserId(request);
|
|
1177
|
+
|
|
1178
|
+
const channelList = await getChannelsWithColors(application, userId);
|
|
1179
|
+
const deckConfig = await getDeckConfig(application, userId);
|
|
1180
|
+
|
|
1181
|
+
// Determine which channels to show as columns
|
|
1182
|
+
let columnChannels;
|
|
1183
|
+
if (deckConfig?.columns?.length > 0) {
|
|
1184
|
+
// Use saved config order
|
|
1185
|
+
const channelMap = new Map(channelList.map((ch) => [ch._id.toString(), ch]));
|
|
1186
|
+
columnChannels = deckConfig.columns
|
|
1187
|
+
.map((col) => channelMap.get(col.channelId.toString()))
|
|
1188
|
+
.filter(Boolean);
|
|
1189
|
+
} else {
|
|
1190
|
+
// Default: all channels except notifications
|
|
1191
|
+
columnChannels = channelList.filter((ch) => ch.uid !== "notifications");
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// Fetch items for each column (limited to 10 per column for performance)
|
|
1195
|
+
const proxyBaseUrl = application.url;
|
|
1196
|
+
const columns = await Promise.all(
|
|
1197
|
+
columnChannels.map(async (channel) => {
|
|
1198
|
+
const result = await getTimelineItems(application, channel._id, {
|
|
1199
|
+
userId,
|
|
1200
|
+
limit: 10,
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1203
|
+
if (proxyBaseUrl && result.items) {
|
|
1204
|
+
result.items = result.items.map((item) =>
|
|
1205
|
+
proxyItemImages(item, proxyBaseUrl),
|
|
1206
|
+
);
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
return {
|
|
1210
|
+
channel,
|
|
1211
|
+
items: result.items,
|
|
1212
|
+
paging: result.paging,
|
|
1213
|
+
};
|
|
1214
|
+
}),
|
|
1215
|
+
);
|
|
1216
|
+
|
|
1217
|
+
// Set view preference cookie
|
|
1218
|
+
if (request.session) request.session.microsubView = "deck";
|
|
1219
|
+
|
|
1220
|
+
response.render("deck", {
|
|
1221
|
+
title: "Deck",
|
|
1222
|
+
columns,
|
|
1223
|
+
baseUrl: request.baseUrl,
|
|
1224
|
+
readerBaseUrl: request.baseUrl,
|
|
1225
|
+
activeView: "deck",
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/**
|
|
1230
|
+
* Deck settings page
|
|
1231
|
+
* @param {object} request - Express request
|
|
1232
|
+
* @param {object} response - Express response
|
|
1233
|
+
*/
|
|
1234
|
+
export async function deckSettings(request, response) {
|
|
1235
|
+
const { application } = request.app.locals;
|
|
1236
|
+
const userId = getUserId(request);
|
|
1237
|
+
|
|
1238
|
+
const channelList = await getChannelsWithColors(application, userId);
|
|
1239
|
+
const deckConfig = await getDeckConfig(application, userId);
|
|
1240
|
+
|
|
1241
|
+
const selectedIds = deckConfig?.columns
|
|
1242
|
+
? deckConfig.columns.map((col) => col.channelId.toString())
|
|
1243
|
+
: channelList.filter((ch) => ch.uid !== "notifications").map((ch) => ch._id.toString());
|
|
1244
|
+
|
|
1245
|
+
response.render("deck-settings", {
|
|
1246
|
+
title: "Deck settings",
|
|
1247
|
+
channels: channelList,
|
|
1248
|
+
selectedIds,
|
|
1249
|
+
baseUrl: request.baseUrl,
|
|
1250
|
+
readerBaseUrl: request.baseUrl,
|
|
1251
|
+
activeView: "deck",
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
/**
|
|
1256
|
+
* Save deck settings
|
|
1257
|
+
* @param {object} request - Express request
|
|
1258
|
+
* @param {object} response - Express response
|
|
1259
|
+
*/
|
|
1260
|
+
export async function saveDeckSettings(request, response) {
|
|
1261
|
+
const { application } = request.app.locals;
|
|
1262
|
+
const userId = getUserId(request);
|
|
1263
|
+
|
|
1264
|
+
let { columns } = request.body;
|
|
1265
|
+
if (!columns) columns = [];
|
|
1266
|
+
if (!Array.isArray(columns)) columns = [columns];
|
|
1267
|
+
|
|
1268
|
+
await saveDeckConfig(application, userId, columns);
|
|
1269
|
+
|
|
1270
|
+
response.redirect(`${request.baseUrl}/deck`);
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1062
1273
|
export const readerController = {
|
|
1063
1274
|
index,
|
|
1064
1275
|
channels,
|
|
@@ -1086,4 +1297,8 @@ export const readerController = {
|
|
|
1086
1297
|
actorProfile,
|
|
1087
1298
|
followActorAction,
|
|
1088
1299
|
unfollowActorAction,
|
|
1300
|
+
timeline,
|
|
1301
|
+
deck,
|
|
1302
|
+
deckSettings,
|
|
1303
|
+
saveDeckSettings,
|
|
1089
1304
|
};
|
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
|
@@ -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,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" style="border-left: 4px solid {{ item._channelColor or '#ccc' }}">
|
|
35
|
+
{% include "partials/item-card.njk" %}
|
|
36
|
+
{% if item._channelName %}
|
|
37
|
+
<span class="timeline-view__channel-label" style="color: {{ item._channelColor or '#888' }}">
|
|
38
|
+
{{ item._channelName }}
|
|
39
|
+
</span>
|
|
40
|
+
{% endif %}
|
|
41
|
+
</div>
|
|
42
|
+
{% endfor %}
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
{% if paging %}
|
|
46
|
+
<nav class="timeline__paging" aria-label="Pagination">
|
|
47
|
+
{% if paging.before %}
|
|
48
|
+
<a href="?before={{ paging.before }}" class="button button--secondary">
|
|
49
|
+
{{ __("microsub.reader.newer") }}
|
|
50
|
+
</a>
|
|
51
|
+
{% else %}
|
|
52
|
+
<span></span>
|
|
53
|
+
{% endif %}
|
|
54
|
+
{% if paging.after %}
|
|
55
|
+
<a href="?after={{ paging.after }}" class="button button--secondary">
|
|
56
|
+
{{ __("microsub.reader.older") }}
|
|
57
|
+
</a>
|
|
58
|
+
{% endif %}
|
|
59
|
+
</nav>
|
|
60
|
+
{% endif %}
|
|
61
|
+
|
|
62
|
+
{% else %}
|
|
63
|
+
<div class="reader__empty">
|
|
64
|
+
<p>{{ __("microsub.reader.empty") }}</p>
|
|
65
|
+
</div>
|
|
66
|
+
{% endif %}
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<script type="module">
|
|
70
|
+
const timeline = document.getElementById('timeline');
|
|
71
|
+
if (timeline) {
|
|
72
|
+
const items = Array.from(timeline.querySelectorAll('.item-card'));
|
|
73
|
+
let currentIndex = -1;
|
|
74
|
+
|
|
75
|
+
function focusItem(index) {
|
|
76
|
+
if (items[currentIndex]) items[currentIndex].classList.remove('item-card--focused');
|
|
77
|
+
currentIndex = Math.max(0, Math.min(index, items.length - 1));
|
|
78
|
+
if (items[currentIndex]) {
|
|
79
|
+
items[currentIndex].classList.add('item-card--focused');
|
|
80
|
+
items[currentIndex].scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
document.addEventListener('keydown', (e) => {
|
|
85
|
+
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
|
86
|
+
switch(e.key) {
|
|
87
|
+
case 'j': e.preventDefault(); focusItem(currentIndex + 1); break;
|
|
88
|
+
case 'k': e.preventDefault(); focusItem(currentIndex - 1); break;
|
|
89
|
+
case 'o': case 'Enter':
|
|
90
|
+
e.preventDefault();
|
|
91
|
+
if (items[currentIndex]) {
|
|
92
|
+
const link = items[currentIndex].querySelector('.item-card__link');
|
|
93
|
+
if (link) link.click();
|
|
94
|
+
}
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
</script>
|
|
100
|
+
{% endblock %}
|