@rmdes/indiekit-endpoint-microsub 1.0.36 → 1.0.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/styles.css +287 -0
- package/index.js +5 -0
- package/lib/controllers/reader.js +226 -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/actor.njk +6 -2
- 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/item-card.njk +6 -2
- 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,
|
|
@@ -35,6 +37,8 @@ import {
|
|
|
35
37
|
validateExcludeTypes,
|
|
36
38
|
validateExcludeRegex,
|
|
37
39
|
} from "../utils/validation.js";
|
|
40
|
+
import { proxyItemImages } from "../media/proxy.js";
|
|
41
|
+
import { getDeckConfig, saveDeckConfig } from "../storage/deck.js";
|
|
38
42
|
|
|
39
43
|
/**
|
|
40
44
|
* Reader index - redirect to channels
|
|
@@ -42,7 +46,10 @@ import {
|
|
|
42
46
|
* @param {object} response - Express response
|
|
43
47
|
*/
|
|
44
48
|
export async function index(request, response) {
|
|
45
|
-
|
|
49
|
+
const lastView = request.session?.microsubView || "channels";
|
|
50
|
+
const validViews = ["channels", "deck", "timeline"];
|
|
51
|
+
const view = validViews.includes(lastView) ? lastView : "channels";
|
|
52
|
+
response.redirect(`${request.baseUrl}/${view}`);
|
|
46
53
|
}
|
|
47
54
|
|
|
48
55
|
/**
|
|
@@ -56,10 +63,14 @@ export async function channels(request, response) {
|
|
|
56
63
|
|
|
57
64
|
const channelList = await getChannels(application, userId);
|
|
58
65
|
|
|
66
|
+
if (request.session) request.session.microsubView = "channels";
|
|
67
|
+
|
|
59
68
|
response.render("reader", {
|
|
60
|
-
title: request.__("microsub.
|
|
69
|
+
title: request.__("microsub.views.channels"),
|
|
61
70
|
channels: channelList,
|
|
62
71
|
baseUrl: request.baseUrl,
|
|
72
|
+
readerBaseUrl: request.baseUrl,
|
|
73
|
+
activeView: "channels",
|
|
63
74
|
});
|
|
64
75
|
}
|
|
65
76
|
|
|
@@ -72,6 +83,8 @@ export async function newChannel(request, response) {
|
|
|
72
83
|
response.render("channel-new", {
|
|
73
84
|
title: request.__("microsub.channels.new"),
|
|
74
85
|
baseUrl: request.baseUrl,
|
|
86
|
+
readerBaseUrl: request.baseUrl,
|
|
87
|
+
activeView: "channels",
|
|
75
88
|
});
|
|
76
89
|
}
|
|
77
90
|
|
|
@@ -119,6 +132,14 @@ export async function channel(request, response) {
|
|
|
119
132
|
showRead: showReadItems,
|
|
120
133
|
});
|
|
121
134
|
|
|
135
|
+
// Proxy images through media endpoint for privacy
|
|
136
|
+
const proxyBaseUrl = application.url;
|
|
137
|
+
if (proxyBaseUrl && timeline.items) {
|
|
138
|
+
timeline.items = timeline.items.map((item) =>
|
|
139
|
+
proxyItemImages(item, proxyBaseUrl),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
122
143
|
// Count read items to show "View read items" button
|
|
123
144
|
const readCount = await countReadItems(
|
|
124
145
|
application,
|
|
@@ -134,6 +155,8 @@ export async function channel(request, response) {
|
|
|
134
155
|
readCount,
|
|
135
156
|
showRead: showReadItems,
|
|
136
157
|
baseUrl: request.baseUrl,
|
|
158
|
+
readerBaseUrl: request.baseUrl,
|
|
159
|
+
activeView: "channels",
|
|
137
160
|
});
|
|
138
161
|
}
|
|
139
162
|
|
|
@@ -159,6 +182,8 @@ export async function settings(request, response) {
|
|
|
159
182
|
}),
|
|
160
183
|
channel: channelDocument,
|
|
161
184
|
baseUrl: request.baseUrl,
|
|
185
|
+
readerBaseUrl: request.baseUrl,
|
|
186
|
+
activeView: "channels",
|
|
162
187
|
});
|
|
163
188
|
}
|
|
164
189
|
|
|
@@ -246,6 +271,8 @@ export async function feeds(request, response) {
|
|
|
246
271
|
channel: channelDocument,
|
|
247
272
|
feeds: feedList,
|
|
248
273
|
baseUrl: request.baseUrl,
|
|
274
|
+
readerBaseUrl: request.baseUrl,
|
|
275
|
+
activeView: "channels",
|
|
249
276
|
});
|
|
250
277
|
}
|
|
251
278
|
|
|
@@ -332,6 +359,8 @@ export async function item(request, response) {
|
|
|
332
359
|
item: itemDocument,
|
|
333
360
|
channel,
|
|
334
361
|
baseUrl: request.baseUrl,
|
|
362
|
+
readerBaseUrl: request.baseUrl,
|
|
363
|
+
activeView: "channels",
|
|
335
364
|
});
|
|
336
365
|
}
|
|
337
366
|
|
|
@@ -442,6 +471,8 @@ export async function compose(request, response) {
|
|
|
442
471
|
bookmarkOf: ensureString(bookmarkOf || bookmark),
|
|
443
472
|
syndicationTargets,
|
|
444
473
|
baseUrl: request.baseUrl,
|
|
474
|
+
readerBaseUrl: request.baseUrl,
|
|
475
|
+
activeView: "channels",
|
|
445
476
|
});
|
|
446
477
|
}
|
|
447
478
|
|
|
@@ -615,6 +646,8 @@ export async function searchPage(request, response) {
|
|
|
615
646
|
title: request.__("microsub.search.title"),
|
|
616
647
|
channels: channelList,
|
|
617
648
|
baseUrl: request.baseUrl,
|
|
649
|
+
readerBaseUrl: request.baseUrl,
|
|
650
|
+
activeView: "channels",
|
|
618
651
|
});
|
|
619
652
|
}
|
|
620
653
|
|
|
@@ -651,6 +684,8 @@ export async function searchFeeds(request, response) {
|
|
|
651
684
|
discoveryError,
|
|
652
685
|
searched: true,
|
|
653
686
|
baseUrl: request.baseUrl,
|
|
687
|
+
readerBaseUrl: request.baseUrl,
|
|
688
|
+
activeView: "channels",
|
|
654
689
|
});
|
|
655
690
|
}
|
|
656
691
|
|
|
@@ -682,6 +717,8 @@ export async function subscribe(request, response) {
|
|
|
682
717
|
query: url,
|
|
683
718
|
validationError: validation.error,
|
|
684
719
|
baseUrl: request.baseUrl,
|
|
720
|
+
readerBaseUrl: request.baseUrl,
|
|
721
|
+
activeView: "channels",
|
|
685
722
|
});
|
|
686
723
|
}
|
|
687
724
|
|
|
@@ -772,6 +809,8 @@ export async function editFeedForm(request, response) {
|
|
|
772
809
|
channel: channelDocument,
|
|
773
810
|
feed,
|
|
774
811
|
baseUrl: request.baseUrl,
|
|
812
|
+
readerBaseUrl: request.baseUrl,
|
|
813
|
+
activeView: "channels",
|
|
775
814
|
});
|
|
776
815
|
}
|
|
777
816
|
|
|
@@ -807,6 +846,8 @@ export async function updateFeedUrl(request, response) {
|
|
|
807
846
|
feed,
|
|
808
847
|
error: validation.error,
|
|
809
848
|
baseUrl: request.baseUrl,
|
|
849
|
+
readerBaseUrl: request.baseUrl,
|
|
850
|
+
activeView: "channels",
|
|
810
851
|
});
|
|
811
852
|
}
|
|
812
853
|
|
|
@@ -986,6 +1027,8 @@ export async function actorProfile(request, response) {
|
|
|
986
1027
|
isFollowing,
|
|
987
1028
|
canFollow,
|
|
988
1029
|
baseUrl: request.baseUrl,
|
|
1030
|
+
readerBaseUrl: request.baseUrl,
|
|
1031
|
+
activeView: "channels",
|
|
989
1032
|
});
|
|
990
1033
|
} catch (error) {
|
|
991
1034
|
console.error(`[Microsub] Actor profile fetch failed: ${error.message}`);
|
|
@@ -997,6 +1040,8 @@ export async function actorProfile(request, response) {
|
|
|
997
1040
|
isFollowing,
|
|
998
1041
|
canFollow,
|
|
999
1042
|
baseUrl: request.baseUrl,
|
|
1043
|
+
readerBaseUrl: request.baseUrl,
|
|
1044
|
+
activeView: "channels",
|
|
1000
1045
|
error: "Could not fetch this actor's profile. They may have restricted access.",
|
|
1001
1046
|
});
|
|
1002
1047
|
}
|
|
@@ -1050,6 +1095,181 @@ export async function unfollowActorAction(request, response) {
|
|
|
1050
1095
|
);
|
|
1051
1096
|
}
|
|
1052
1097
|
|
|
1098
|
+
/**
|
|
1099
|
+
* Timeline view - all channels chronologically
|
|
1100
|
+
* @param {object} request - Express request
|
|
1101
|
+
* @param {object} response - Express response
|
|
1102
|
+
*/
|
|
1103
|
+
export async function timeline(request, response) {
|
|
1104
|
+
const { application } = request.app.locals;
|
|
1105
|
+
const userId = getUserId(request);
|
|
1106
|
+
const { before, after } = request.query;
|
|
1107
|
+
|
|
1108
|
+
// Get channels with colors for filtering UI and item decoration
|
|
1109
|
+
const channelList = await getChannelsWithColors(application, userId);
|
|
1110
|
+
|
|
1111
|
+
// Build channel lookup map (ObjectId string -> { name, color })
|
|
1112
|
+
const channelMap = new Map();
|
|
1113
|
+
for (const ch of channelList) {
|
|
1114
|
+
channelMap.set(ch._id.toString(), { name: ch.name, color: ch.color });
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// Parse excluded channel IDs from query params
|
|
1118
|
+
const excludeParam = request.query.exclude;
|
|
1119
|
+
const excludeIds = excludeParam
|
|
1120
|
+
? (Array.isArray(excludeParam) ? excludeParam : [excludeParam])
|
|
1121
|
+
: [];
|
|
1122
|
+
|
|
1123
|
+
// Exclude the notifications channel by default
|
|
1124
|
+
const notificationsChannel = channelList.find((ch) => ch.uid === "notifications");
|
|
1125
|
+
const excludeChannelIds = [...excludeIds];
|
|
1126
|
+
if (notificationsChannel && !excludeChannelIds.includes(notificationsChannel._id.toString())) {
|
|
1127
|
+
excludeChannelIds.push(notificationsChannel._id.toString());
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
const result = await getAllTimelineItems(application, {
|
|
1131
|
+
before,
|
|
1132
|
+
after,
|
|
1133
|
+
userId,
|
|
1134
|
+
excludeChannelIds,
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
// Proxy images
|
|
1138
|
+
const proxyBaseUrl = application.url;
|
|
1139
|
+
if (proxyBaseUrl && result.items) {
|
|
1140
|
+
result.items = result.items.map((item) => proxyItemImages(item, proxyBaseUrl));
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// Decorate items with channel name and color
|
|
1144
|
+
for (const item of result.items) {
|
|
1145
|
+
if (item._channelId) {
|
|
1146
|
+
const info = channelMap.get(item._channelId);
|
|
1147
|
+
if (info) {
|
|
1148
|
+
item._channelName = info.name;
|
|
1149
|
+
item._channelColor = info.color;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// Set view preference cookie
|
|
1155
|
+
if (request.session) request.session.microsubView = "timeline";
|
|
1156
|
+
|
|
1157
|
+
response.render("timeline", {
|
|
1158
|
+
title: "Timeline",
|
|
1159
|
+
channels: channelList,
|
|
1160
|
+
items: result.items,
|
|
1161
|
+
paging: result.paging,
|
|
1162
|
+
excludeIds,
|
|
1163
|
+
baseUrl: request.baseUrl,
|
|
1164
|
+
readerBaseUrl: request.baseUrl,
|
|
1165
|
+
activeView: "timeline",
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/**
|
|
1170
|
+
* Deck view - TweetDeck-style columns
|
|
1171
|
+
* @param {object} request - Express request
|
|
1172
|
+
* @param {object} response - Express response
|
|
1173
|
+
*/
|
|
1174
|
+
export async function deck(request, response) {
|
|
1175
|
+
const { application } = request.app.locals;
|
|
1176
|
+
const userId = getUserId(request);
|
|
1177
|
+
|
|
1178
|
+
const channelList = await getChannelsWithColors(application, userId);
|
|
1179
|
+
const deckConfig = await getDeckConfig(application, userId);
|
|
1180
|
+
|
|
1181
|
+
// Determine which channels to show as columns
|
|
1182
|
+
let columnChannels;
|
|
1183
|
+
if (deckConfig?.columns?.length > 0) {
|
|
1184
|
+
// Use saved config order
|
|
1185
|
+
const channelMap = new Map(channelList.map((ch) => [ch._id.toString(), ch]));
|
|
1186
|
+
columnChannels = deckConfig.columns
|
|
1187
|
+
.map((col) => channelMap.get(col.channelId.toString()))
|
|
1188
|
+
.filter(Boolean);
|
|
1189
|
+
} else {
|
|
1190
|
+
// Default: all channels except notifications
|
|
1191
|
+
columnChannels = channelList.filter((ch) => ch.uid !== "notifications");
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// Fetch items for each column (limited to 10 per column for performance)
|
|
1195
|
+
const proxyBaseUrl = application.url;
|
|
1196
|
+
const columns = await Promise.all(
|
|
1197
|
+
columnChannels.map(async (channel) => {
|
|
1198
|
+
const result = await getTimelineItems(application, channel._id, {
|
|
1199
|
+
userId,
|
|
1200
|
+
limit: 10,
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1203
|
+
if (proxyBaseUrl && result.items) {
|
|
1204
|
+
result.items = result.items.map((item) =>
|
|
1205
|
+
proxyItemImages(item, proxyBaseUrl),
|
|
1206
|
+
);
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
return {
|
|
1210
|
+
channel,
|
|
1211
|
+
items: result.items,
|
|
1212
|
+
paging: result.paging,
|
|
1213
|
+
};
|
|
1214
|
+
}),
|
|
1215
|
+
);
|
|
1216
|
+
|
|
1217
|
+
// Set view preference cookie
|
|
1218
|
+
if (request.session) request.session.microsubView = "deck";
|
|
1219
|
+
|
|
1220
|
+
response.render("deck", {
|
|
1221
|
+
title: "Deck",
|
|
1222
|
+
columns,
|
|
1223
|
+
baseUrl: request.baseUrl,
|
|
1224
|
+
readerBaseUrl: request.baseUrl,
|
|
1225
|
+
activeView: "deck",
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/**
|
|
1230
|
+
* Deck settings page
|
|
1231
|
+
* @param {object} request - Express request
|
|
1232
|
+
* @param {object} response - Express response
|
|
1233
|
+
*/
|
|
1234
|
+
export async function deckSettings(request, response) {
|
|
1235
|
+
const { application } = request.app.locals;
|
|
1236
|
+
const userId = getUserId(request);
|
|
1237
|
+
|
|
1238
|
+
const channelList = await getChannelsWithColors(application, userId);
|
|
1239
|
+
const deckConfig = await getDeckConfig(application, userId);
|
|
1240
|
+
|
|
1241
|
+
const selectedIds = deckConfig?.columns
|
|
1242
|
+
? deckConfig.columns.map((col) => col.channelId.toString())
|
|
1243
|
+
: channelList.filter((ch) => ch.uid !== "notifications").map((ch) => ch._id.toString());
|
|
1244
|
+
|
|
1245
|
+
response.render("deck-settings", {
|
|
1246
|
+
title: "Deck settings",
|
|
1247
|
+
channels: channelList,
|
|
1248
|
+
selectedIds,
|
|
1249
|
+
baseUrl: request.baseUrl,
|
|
1250
|
+
readerBaseUrl: request.baseUrl,
|
|
1251
|
+
activeView: "deck",
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
/**
|
|
1256
|
+
* Save deck settings
|
|
1257
|
+
* @param {object} request - Express request
|
|
1258
|
+
* @param {object} response - Express response
|
|
1259
|
+
*/
|
|
1260
|
+
export async function saveDeckSettings(request, response) {
|
|
1261
|
+
const { application } = request.app.locals;
|
|
1262
|
+
const userId = getUserId(request);
|
|
1263
|
+
|
|
1264
|
+
let { columns } = request.body;
|
|
1265
|
+
if (!columns) columns = [];
|
|
1266
|
+
if (!Array.isArray(columns)) columns = [columns];
|
|
1267
|
+
|
|
1268
|
+
await saveDeckConfig(application, userId, columns);
|
|
1269
|
+
|
|
1270
|
+
response.redirect(`${request.baseUrl}/deck`);
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1053
1273
|
export const readerController = {
|
|
1054
1274
|
index,
|
|
1055
1275
|
channels,
|
|
@@ -1077,4 +1297,8 @@ export const readerController = {
|
|
|
1077
1297
|
actorProfile,
|
|
1078
1298
|
followActorAction,
|
|
1079
1299
|
unfollowActorAction,
|
|
1300
|
+
timeline,
|
|
1301
|
+
deck,
|
|
1302
|
+
deckSettings,
|
|
1303
|
+
saveDeckSettings,
|
|
1080
1304
|
};
|
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/actor.njk
CHANGED
|
@@ -121,8 +121,10 @@
|
|
|
121
121
|
{# Tags #}
|
|
122
122
|
{% if item.category and item.category.length > 0 %}
|
|
123
123
|
<div class="item-card__categories">
|
|
124
|
-
{% for cat in item.category
|
|
124
|
+
{% for cat in item.category %}
|
|
125
|
+
{% if loop.index0 < 5 %}
|
|
125
126
|
<span class="item-card__category">#{{ cat }}</span>
|
|
127
|
+
{% endif %}
|
|
126
128
|
{% endfor %}
|
|
127
129
|
</div>
|
|
128
130
|
{% endif %}
|
|
@@ -131,9 +133,11 @@
|
|
|
131
133
|
{% if item.photo and item.photo.length > 0 %}
|
|
132
134
|
{% set photoCount = item.photo.length if item.photo.length <= 4 else 4 %}
|
|
133
135
|
<div class="item-card__photos item-card__photos--{{ photoCount }}">
|
|
134
|
-
{% for photo in item.photo
|
|
136
|
+
{% for photo in item.photo %}
|
|
137
|
+
{% if loop.index0 < 4 %}
|
|
135
138
|
<img src="{{ photo }}" alt="" class="item-card__photo" loading="lazy"
|
|
136
139
|
onerror="this.parentElement.removeChild(this)">
|
|
140
|
+
{% endif %}
|
|
137
141
|
{% endfor %}
|
|
138
142
|
</div>
|
|
139
143
|
{% endif %}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{% extends "layouts/reader.njk" %}
|
|
2
|
+
|
|
3
|
+
{% block reader %}
|
|
4
|
+
<div class="settings">
|
|
5
|
+
<header>
|
|
6
|
+
<a href="{{ baseUrl }}/deck" class="back-link">
|
|
7
|
+
{{ __("microsub.views.deck") }}
|
|
8
|
+
</a>
|
|
9
|
+
<h1>Deck columns</h1>
|
|
10
|
+
</header>
|
|
11
|
+
|
|
12
|
+
<form action="{{ baseUrl }}/deck/settings" method="POST">
|
|
13
|
+
<p>Select which channels appear as columns in your deck, and their order.</p>
|
|
14
|
+
|
|
15
|
+
<div class="deck-settings__channels">
|
|
16
|
+
{% for channel in channels %}
|
|
17
|
+
{% if channel.uid !== "notifications" %}
|
|
18
|
+
<label class="deck-settings__channel">
|
|
19
|
+
<input type="checkbox" name="columns" value="{{ channel._id }}"
|
|
20
|
+
{% if channel._id.toString() in selectedIds %}checked{% endif %}>
|
|
21
|
+
<span class="timeline-view__filter-color" style="background: {{ channel.color }}"></span>
|
|
22
|
+
{{ channel.name }}
|
|
23
|
+
</label>
|
|
24
|
+
{% endif %}
|
|
25
|
+
{% endfor %}
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<button type="submit" class="button button--primary">
|
|
29
|
+
Save deck configuration
|
|
30
|
+
</button>
|
|
31
|
+
</form>
|
|
32
|
+
</div>
|
|
33
|
+
{% endblock %}
|
package/views/deck.njk
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{% extends "layouts/reader.njk" %}
|
|
2
|
+
|
|
3
|
+
{% block reader %}
|
|
4
|
+
<div class="deck">
|
|
5
|
+
<header class="deck__header">
|
|
6
|
+
<h1>{{ __("microsub.views.deck") }}</h1>
|
|
7
|
+
<a href="{{ baseUrl }}/deck/settings" class="button button--secondary button--small">
|
|
8
|
+
Configure columns
|
|
9
|
+
</a>
|
|
10
|
+
</header>
|
|
11
|
+
|
|
12
|
+
{% if columns.length > 0 %}
|
|
13
|
+
<div class="deck__columns">
|
|
14
|
+
{% for col in columns %}
|
|
15
|
+
<div class="deck__column" data-channel-uid="{{ col.channel.uid }}">
|
|
16
|
+
<div class="deck__column-header" style="border-top: 3px solid {{ col.channel.color or '#ccc' }}">
|
|
17
|
+
<a href="{{ baseUrl }}/channels/{{ col.channel.uid }}" class="deck__column-name">
|
|
18
|
+
{{ col.channel.name }}
|
|
19
|
+
</a>
|
|
20
|
+
{% if col.channel.unread %}
|
|
21
|
+
<span class="reader__channel-badge{% if col.channel.unread === true %} reader__channel-badge--dot{% endif %}">
|
|
22
|
+
{% if col.channel.unread !== true %}{{ col.channel.unread }}{% endif %}
|
|
23
|
+
</span>
|
|
24
|
+
{% endif %}
|
|
25
|
+
</div>
|
|
26
|
+
<div class="deck__column-items">
|
|
27
|
+
{% for item in col.items %}
|
|
28
|
+
{% include "partials/item-card-compact.njk" %}
|
|
29
|
+
{% endfor %}
|
|
30
|
+
{% if col.items.length === 0 %}
|
|
31
|
+
<p class="deck__column-empty">No unread items</p>
|
|
32
|
+
{% endif %}
|
|
33
|
+
{% if col.paging and col.paging.after %}
|
|
34
|
+
<a href="{{ baseUrl }}/channels/{{ col.channel.uid }}"
|
|
35
|
+
class="deck__column-more button button--secondary button--small">
|
|
36
|
+
View more
|
|
37
|
+
</a>
|
|
38
|
+
{% endif %}
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
{% endfor %}
|
|
42
|
+
</div>
|
|
43
|
+
{% else %}
|
|
44
|
+
<div class="reader__empty">
|
|
45
|
+
<p>No columns configured. Add channels to your deck.</p>
|
|
46
|
+
<a href="{{ baseUrl }}/deck/settings" class="button button--primary">
|
|
47
|
+
Configure deck
|
|
48
|
+
</a>
|
|
49
|
+
</div>
|
|
50
|
+
{% endif %}
|
|
51
|
+
</div>
|
|
52
|
+
{% endblock %}
|
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>
|
|
@@ -111,8 +111,10 @@
|
|
|
111
111
|
{# Categories/Tags #}
|
|
112
112
|
{% if item.category and item.category.length > 0 %}
|
|
113
113
|
<div class="item-card__categories">
|
|
114
|
-
{% for cat in item.category
|
|
114
|
+
{% for cat in item.category %}
|
|
115
|
+
{% if loop.index0 < 5 %}
|
|
115
116
|
<span class="item-card__category">#{{ cat | replace("#", "") }}</span>
|
|
117
|
+
{% endif %}
|
|
116
118
|
{% endfor %}
|
|
117
119
|
</div>
|
|
118
120
|
{% endif %}
|
|
@@ -121,12 +123,14 @@
|
|
|
121
123
|
{% if item.photo and item.photo.length > 0 %}
|
|
122
124
|
{% set photoCount = item.photo.length if item.photo.length <= 4 else 4 %}
|
|
123
125
|
<div class="item-card__photos item-card__photos--{{ photoCount }}">
|
|
124
|
-
{% for photo in item.photo
|
|
126
|
+
{% for photo in item.photo %}
|
|
127
|
+
{% if loop.index0 < 4 %}
|
|
125
128
|
<img src="{{ photo }}"
|
|
126
129
|
alt=""
|
|
127
130
|
class="item-card__photo"
|
|
128
131
|
loading="lazy"
|
|
129
132
|
onerror="this.parentElement.removeChild(this)">
|
|
133
|
+
{% endif %}
|
|
130
134
|
{% endfor %}
|
|
131
135
|
</div>
|
|
132
136
|
{% endif %}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{# View mode switcher - icon toolbar #}
|
|
2
|
+
<nav class="view-switcher" aria-label="View mode">
|
|
3
|
+
<a href="{{ readerBaseUrl }}/channels"
|
|
4
|
+
class="view-switcher__button{% if activeView === 'channels' %} view-switcher__button--active{% endif %}"
|
|
5
|
+
title="Channels">
|
|
6
|
+
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
7
|
+
<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/>
|
|
8
|
+
<line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>
|
|
9
|
+
</svg>
|
|
10
|
+
</a>
|
|
11
|
+
<a href="{{ readerBaseUrl }}/deck"
|
|
12
|
+
class="view-switcher__button{% if activeView === 'deck' %} view-switcher__button--active{% endif %}"
|
|
13
|
+
title="Deck">
|
|
14
|
+
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
15
|
+
<rect x="3" y="3" width="7" height="18" rx="1"/><rect x="14" y="3" width="7" height="18" rx="1"/>
|
|
16
|
+
</svg>
|
|
17
|
+
</a>
|
|
18
|
+
<a href="{{ readerBaseUrl }}/timeline"
|
|
19
|
+
class="view-switcher__button{% if activeView === 'timeline' %} view-switcher__button--active{% endif %}"
|
|
20
|
+
title="Timeline">
|
|
21
|
+
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
22
|
+
<line x1="12" y1="2" x2="12" y2="22"/><polyline points="19 15 12 22 5 15"/>
|
|
23
|
+
</svg>
|
|
24
|
+
</a>
|
|
25
|
+
</nav>
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
{% extends "layouts/reader.njk" %}
|
|
2
|
+
|
|
3
|
+
{% block reader %}
|
|
4
|
+
<div class="timeline-view">
|
|
5
|
+
<header class="timeline-view__header">
|
|
6
|
+
<h1>{{ __("microsub.views.timeline") }}</h1>
|
|
7
|
+
<div class="timeline-view__actions">
|
|
8
|
+
{% if channels.length > 0 %}
|
|
9
|
+
<details class="timeline-view__filter">
|
|
10
|
+
<summary class="button button--secondary button--small">
|
|
11
|
+
Filter channels
|
|
12
|
+
</summary>
|
|
13
|
+
<form action="{{ baseUrl }}/timeline" method="GET" class="timeline-view__filter-form">
|
|
14
|
+
{% for ch in channels %}
|
|
15
|
+
{% if ch.uid !== "notifications" %}
|
|
16
|
+
<label class="timeline-view__filter-label">
|
|
17
|
+
<input type="checkbox" name="exclude" value="{{ ch._id }}"
|
|
18
|
+
{% if excludeIds and ch._id.toString() in excludeIds %}checked{% endif %}>
|
|
19
|
+
<span class="timeline-view__filter-color" style="background: {{ ch.color }}"></span>
|
|
20
|
+
{{ ch.name }}
|
|
21
|
+
</label>
|
|
22
|
+
{% endif %}
|
|
23
|
+
{% endfor %}
|
|
24
|
+
<button type="submit" class="button button--primary button--small">Apply</button>
|
|
25
|
+
</form>
|
|
26
|
+
</details>
|
|
27
|
+
{% endif %}
|
|
28
|
+
</div>
|
|
29
|
+
</header>
|
|
30
|
+
|
|
31
|
+
{% if items.length > 0 %}
|
|
32
|
+
<div class="timeline" id="timeline">
|
|
33
|
+
{% for item in items %}
|
|
34
|
+
<div class="timeline-view__item" style="border-left: 4px solid {{ item._channelColor or '#ccc' }}">
|
|
35
|
+
{% include "partials/item-card.njk" %}
|
|
36
|
+
{% if item._channelName %}
|
|
37
|
+
<span class="timeline-view__channel-label" style="color: {{ item._channelColor or '#888' }}">
|
|
38
|
+
{{ item._channelName }}
|
|
39
|
+
</span>
|
|
40
|
+
{% endif %}
|
|
41
|
+
</div>
|
|
42
|
+
{% endfor %}
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
{% if paging %}
|
|
46
|
+
<nav class="timeline__paging" aria-label="Pagination">
|
|
47
|
+
{% if paging.before %}
|
|
48
|
+
<a href="?before={{ paging.before }}" class="button button--secondary">
|
|
49
|
+
{{ __("microsub.reader.newer") }}
|
|
50
|
+
</a>
|
|
51
|
+
{% else %}
|
|
52
|
+
<span></span>
|
|
53
|
+
{% endif %}
|
|
54
|
+
{% if paging.after %}
|
|
55
|
+
<a href="?after={{ paging.after }}" class="button button--secondary">
|
|
56
|
+
{{ __("microsub.reader.older") }}
|
|
57
|
+
</a>
|
|
58
|
+
{% endif %}
|
|
59
|
+
</nav>
|
|
60
|
+
{% endif %}
|
|
61
|
+
|
|
62
|
+
{% else %}
|
|
63
|
+
<div class="reader__empty">
|
|
64
|
+
<p>{{ __("microsub.reader.empty") }}</p>
|
|
65
|
+
</div>
|
|
66
|
+
{% endif %}
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<script type="module">
|
|
70
|
+
const timeline = document.getElementById('timeline');
|
|
71
|
+
if (timeline) {
|
|
72
|
+
const items = Array.from(timeline.querySelectorAll('.item-card'));
|
|
73
|
+
let currentIndex = -1;
|
|
74
|
+
|
|
75
|
+
function focusItem(index) {
|
|
76
|
+
if (items[currentIndex]) items[currentIndex].classList.remove('item-card--focused');
|
|
77
|
+
currentIndex = Math.max(0, Math.min(index, items.length - 1));
|
|
78
|
+
if (items[currentIndex]) {
|
|
79
|
+
items[currentIndex].classList.add('item-card--focused');
|
|
80
|
+
items[currentIndex].scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
document.addEventListener('keydown', (e) => {
|
|
85
|
+
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
|
86
|
+
switch(e.key) {
|
|
87
|
+
case 'j': e.preventDefault(); focusItem(currentIndex + 1); break;
|
|
88
|
+
case 'k': e.preventDefault(); focusItem(currentIndex - 1); break;
|
|
89
|
+
case 'o': case 'Enter':
|
|
90
|
+
e.preventDefault();
|
|
91
|
+
if (items[currentIndex]) {
|
|
92
|
+
const link = items[currentIndex].querySelector('.item-card__link');
|
|
93
|
+
if (link) link.click();
|
|
94
|
+
}
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
</script>
|
|
100
|
+
{% endblock %}
|