@rmdes/indiekit-endpoint-microsub 1.0.38 → 1.0.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/assets/styles.css CHANGED
@@ -1015,6 +1015,49 @@
1015
1015
  color: #7c3aed;
1016
1016
  }
1017
1017
 
1018
+ /* ==========================================================================
1019
+ Breadcrumbs
1020
+ ========================================================================== */
1021
+
1022
+ .breadcrumbs {
1023
+ margin-bottom: var(--space-xs);
1024
+ }
1025
+
1026
+ .breadcrumbs__list {
1027
+ align-items: center;
1028
+ display: flex;
1029
+ flex-wrap: wrap;
1030
+ font-size: var(--font-size-small);
1031
+ gap: 0;
1032
+ list-style: none;
1033
+ margin: 0;
1034
+ padding: 0;
1035
+ }
1036
+
1037
+ .breadcrumbs__item::before {
1038
+ color: var(--color-text-muted);
1039
+ content: "/";
1040
+ margin: 0 var(--space-xs);
1041
+ }
1042
+
1043
+ .breadcrumbs__item:first-child::before {
1044
+ content: none;
1045
+ margin: 0;
1046
+ }
1047
+
1048
+ .breadcrumbs__link {
1049
+ color: var(--color-primary);
1050
+ text-decoration: none;
1051
+ }
1052
+
1053
+ .breadcrumbs__link:hover {
1054
+ text-decoration: underline;
1055
+ }
1056
+
1057
+ .breadcrumbs__current {
1058
+ color: var(--color-text-muted);
1059
+ }
1060
+
1018
1061
  /* ==========================================================================
1019
1062
  View Switcher
1020
1063
  ========================================================================== */
@@ -1072,19 +1115,20 @@
1072
1115
  }
1073
1116
 
1074
1117
  .timeline-view__item {
1075
- border-radius: var(--border-radius);
1076
1118
  position: relative;
1077
1119
  }
1078
1120
 
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;
1121
+ .timeline-view__channel-badge {
1122
+ border-radius: 3px;
1123
+ color: #fff;
1124
+ display: inline-block;
1125
+ font-size: 0.6875rem;
1086
1126
  font-weight: 600;
1087
- padding: 0 var(--space-s) var(--space-xs);
1127
+ letter-spacing: 0.02em;
1128
+ line-height: 1;
1129
+ margin-bottom: var(--space-xs);
1130
+ padding: 3px 8px;
1131
+ text-transform: uppercase;
1088
1132
  }
1089
1133
 
1090
1134
  .timeline-view__filter {
@@ -46,9 +46,9 @@ import { getDeckConfig, saveDeckConfig } from "../storage/deck.js";
46
46
  * @param {object} response - Express response
47
47
  */
48
48
  export async function index(request, response) {
49
- const lastView = request.session?.microsubView || "channels";
49
+ const lastView = request.session?.microsubView || "timeline";
50
50
  const validViews = ["channels", "deck", "timeline"];
51
- const view = validViews.includes(lastView) ? lastView : "channels";
51
+ const view = validViews.includes(lastView) ? lastView : "timeline";
52
52
  response.redirect(`${request.baseUrl}/${view}`);
53
53
  }
54
54
 
@@ -71,6 +71,10 @@ export async function channels(request, response) {
71
71
  baseUrl: request.baseUrl,
72
72
  readerBaseUrl: request.baseUrl,
73
73
  activeView: "channels",
74
+ breadcrumbs: [
75
+ { text: "Reader", href: request.baseUrl },
76
+ { text: "Channels" },
77
+ ],
74
78
  });
75
79
  }
76
80
 
@@ -85,6 +89,11 @@ export async function newChannel(request, response) {
85
89
  baseUrl: request.baseUrl,
86
90
  readerBaseUrl: request.baseUrl,
87
91
  activeView: "channels",
92
+ breadcrumbs: [
93
+ { text: "Reader", href: request.baseUrl },
94
+ { text: "Channels", href: `${request.baseUrl}/channels` },
95
+ { text: request.__("microsub.channels.new") },
96
+ ],
88
97
  });
89
98
  }
90
99
 
@@ -157,6 +166,11 @@ export async function channel(request, response) {
157
166
  baseUrl: request.baseUrl,
158
167
  readerBaseUrl: request.baseUrl,
159
168
  activeView: "channels",
169
+ breadcrumbs: [
170
+ { text: "Reader", href: request.baseUrl },
171
+ { text: "Channels", href: `${request.baseUrl}/channels` },
172
+ { text: channelDocument.name },
173
+ ],
160
174
  });
161
175
  }
162
176
 
@@ -184,6 +198,12 @@ export async function settings(request, response) {
184
198
  baseUrl: request.baseUrl,
185
199
  readerBaseUrl: request.baseUrl,
186
200
  activeView: "channels",
201
+ breadcrumbs: [
202
+ { text: "Reader", href: request.baseUrl },
203
+ { text: "Channels", href: `${request.baseUrl}/channels` },
204
+ { text: channelDocument.name, href: `${request.baseUrl}/channels/${uid}` },
205
+ { text: "Settings" },
206
+ ],
187
207
  });
188
208
  }
189
209
 
@@ -273,6 +293,12 @@ export async function feeds(request, response) {
273
293
  baseUrl: request.baseUrl,
274
294
  readerBaseUrl: request.baseUrl,
275
295
  activeView: "channels",
296
+ breadcrumbs: [
297
+ { text: "Reader", href: request.baseUrl },
298
+ { text: "Channels", href: `${request.baseUrl}/channels` },
299
+ { text: channelDocument.name, href: `${request.baseUrl}/channels/${uid}` },
300
+ { text: "Feeds" },
301
+ ],
276
302
  });
277
303
  }
278
304
 
@@ -354,6 +380,17 @@ export async function item(request, response) {
354
380
  channel = await channelsCollection.findOne({ _id: itemDocument.channelId });
355
381
  }
356
382
 
383
+ const itemBreadcrumbs = [
384
+ { text: "Reader", href: request.baseUrl },
385
+ ];
386
+ if (channel) {
387
+ itemBreadcrumbs.push(
388
+ { text: "Channels", href: `${request.baseUrl}/channels` },
389
+ { text: channel.name, href: `${request.baseUrl}/channels/${channel.uid}` },
390
+ );
391
+ }
392
+ itemBreadcrumbs.push({ text: itemDocument.name || "Item" });
393
+
357
394
  response.render("item", {
358
395
  title: itemDocument.name || "Item",
359
396
  item: itemDocument,
@@ -361,6 +398,7 @@ export async function item(request, response) {
361
398
  baseUrl: request.baseUrl,
362
399
  readerBaseUrl: request.baseUrl,
363
400
  activeView: "channels",
401
+ breadcrumbs: itemBreadcrumbs,
364
402
  });
365
403
  }
366
404
 
@@ -473,6 +511,10 @@ export async function compose(request, response) {
473
511
  baseUrl: request.baseUrl,
474
512
  readerBaseUrl: request.baseUrl,
475
513
  activeView: "channels",
514
+ breadcrumbs: [
515
+ { text: "Reader", href: request.baseUrl },
516
+ { text: "Compose" },
517
+ ],
476
518
  });
477
519
  }
478
520
 
@@ -648,6 +690,10 @@ export async function searchPage(request, response) {
648
690
  baseUrl: request.baseUrl,
649
691
  readerBaseUrl: request.baseUrl,
650
692
  activeView: "channels",
693
+ breadcrumbs: [
694
+ { text: "Reader", href: request.baseUrl },
695
+ { text: "Search" },
696
+ ],
651
697
  });
652
698
  }
653
699
 
@@ -686,6 +732,10 @@ export async function searchFeeds(request, response) {
686
732
  baseUrl: request.baseUrl,
687
733
  readerBaseUrl: request.baseUrl,
688
734
  activeView: "channels",
735
+ breadcrumbs: [
736
+ { text: "Reader", href: request.baseUrl },
737
+ { text: "Search" },
738
+ ],
689
739
  });
690
740
  }
691
741
 
@@ -719,6 +769,10 @@ export async function subscribe(request, response) {
719
769
  baseUrl: request.baseUrl,
720
770
  readerBaseUrl: request.baseUrl,
721
771
  activeView: "channels",
772
+ breadcrumbs: [
773
+ { text: "Reader", href: request.baseUrl },
774
+ { text: "Search" },
775
+ ],
722
776
  });
723
777
  }
724
778
 
@@ -811,6 +865,13 @@ export async function editFeedForm(request, response) {
811
865
  baseUrl: request.baseUrl,
812
866
  readerBaseUrl: request.baseUrl,
813
867
  activeView: "channels",
868
+ breadcrumbs: [
869
+ { text: "Reader", href: request.baseUrl },
870
+ { text: "Channels", href: `${request.baseUrl}/channels` },
871
+ { text: channelDocument.name, href: `${request.baseUrl}/channels/${uid}` },
872
+ { text: "Feeds", href: `${request.baseUrl}/channels/${uid}/feeds` },
873
+ { text: "Edit" },
874
+ ],
814
875
  });
815
876
  }
816
877
 
@@ -848,6 +909,13 @@ export async function updateFeedUrl(request, response) {
848
909
  baseUrl: request.baseUrl,
849
910
  readerBaseUrl: request.baseUrl,
850
911
  activeView: "channels",
912
+ breadcrumbs: [
913
+ { text: "Reader", href: request.baseUrl },
914
+ { text: "Channels", href: `${request.baseUrl}/channels` },
915
+ { text: channelDocument.name, href: `${request.baseUrl}/channels/${uid}` },
916
+ { text: "Feeds", href: `${request.baseUrl}/channels/${uid}/feeds` },
917
+ { text: "Edit" },
918
+ ],
851
919
  });
852
920
  }
853
921
 
@@ -1029,6 +1097,10 @@ export async function actorProfile(request, response) {
1029
1097
  baseUrl: request.baseUrl,
1030
1098
  readerBaseUrl: request.baseUrl,
1031
1099
  activeView: "channels",
1100
+ breadcrumbs: [
1101
+ { text: "Reader", href: request.baseUrl },
1102
+ { text: actor.name || "Actor" },
1103
+ ],
1032
1104
  });
1033
1105
  } catch (error) {
1034
1106
  console.error(`[Microsub] Actor profile fetch failed: ${error.message}`);
@@ -1043,6 +1115,10 @@ export async function actorProfile(request, response) {
1043
1115
  readerBaseUrl: request.baseUrl,
1044
1116
  activeView: "channels",
1045
1117
  error: "Could not fetch this actor's profile. They may have restricted access.",
1118
+ breadcrumbs: [
1119
+ { text: "Reader", href: request.baseUrl },
1120
+ { text: "Actor" },
1121
+ ],
1046
1122
  });
1047
1123
  }
1048
1124
  }
@@ -1108,10 +1184,10 @@ export async function timeline(request, response) {
1108
1184
  // Get channels with colors for filtering UI and item decoration
1109
1185
  const channelList = await getChannelsWithColors(application, userId);
1110
1186
 
1111
- // Build channel lookup map (ObjectId string -> { name, color })
1187
+ // Build channel lookup map (ObjectId string -> { name, color, uid })
1112
1188
  const channelMap = new Map();
1113
1189
  for (const ch of channelList) {
1114
- channelMap.set(ch._id.toString(), { name: ch.name, color: ch.color });
1190
+ channelMap.set(ch._id.toString(), { name: ch.name, color: ch.color, uid: ch.uid });
1115
1191
  }
1116
1192
 
1117
1193
  // Parse excluded channel IDs from query params
@@ -1147,6 +1223,7 @@ export async function timeline(request, response) {
1147
1223
  if (info) {
1148
1224
  item._channelName = info.name;
1149
1225
  item._channelColor = info.color;
1226
+ item._channelUid = info.uid;
1150
1227
  }
1151
1228
  }
1152
1229
  }
@@ -1163,6 +1240,10 @@ export async function timeline(request, response) {
1163
1240
  baseUrl: request.baseUrl,
1164
1241
  readerBaseUrl: request.baseUrl,
1165
1242
  activeView: "timeline",
1243
+ breadcrumbs: [
1244
+ { text: "Reader", href: request.baseUrl },
1245
+ { text: "Timeline" },
1246
+ ],
1166
1247
  });
1167
1248
  }
1168
1249
 
@@ -1223,6 +1304,10 @@ export async function deck(request, response) {
1223
1304
  baseUrl: request.baseUrl,
1224
1305
  readerBaseUrl: request.baseUrl,
1225
1306
  activeView: "deck",
1307
+ breadcrumbs: [
1308
+ { text: "Reader", href: request.baseUrl },
1309
+ { text: "Deck" },
1310
+ ],
1226
1311
  });
1227
1312
  }
1228
1313
 
@@ -1249,6 +1334,11 @@ export async function deckSettings(request, response) {
1249
1334
  baseUrl: request.baseUrl,
1250
1335
  readerBaseUrl: request.baseUrl,
1251
1336
  activeView: "deck",
1337
+ breadcrumbs: [
1338
+ { text: "Reader", href: request.baseUrl },
1339
+ { text: "Deck", href: `${request.baseUrl}/deck` },
1340
+ { text: "Settings" },
1341
+ ],
1252
1342
  });
1253
1343
  }
1254
1344
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-microsub",
3
- "version": "1.0.38",
3
+ "version": "1.0.40",
4
4
  "description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.",
5
5
  "keywords": [
6
6
  "indiekit",
package/views/channel.njk CHANGED
@@ -3,9 +3,7 @@
3
3
  {% block reader %}
4
4
  <div class="channel">
5
5
  <header class="channel__header">
6
- <a href="{{ baseUrl }}/channels" class="back-link">
7
- {{ icon("previous") }} {{ __("microsub.channels.title") }}
8
- </a>
6
+ <h1>{{ channel.name }}</h1>
9
7
  <div class="channel__actions">
10
8
  {% if not showRead and items.length > 0 %}
11
9
  <form action="{{ baseUrl }}/api/mark-read" method="POST" style="display: inline;">
@@ -6,6 +6,7 @@
6
6
 
7
7
  {% block content %}
8
8
  <link rel="stylesheet" href="/assets/@rmdes-indiekit-endpoint-microsub/styles.css">
9
+ {% include "partials/breadcrumbs.njk" %}
9
10
  {% include "partials/view-switcher.njk" %}
10
11
  {% block reader %}{% endblock %}
11
12
  {% endblock %}
@@ -0,0 +1,16 @@
1
+ {# Breadcrumb navigation #}
2
+ {% if breadcrumbs and breadcrumbs.length > 0 %}
3
+ <nav class="breadcrumbs" aria-label="Breadcrumb">
4
+ <ol class="breadcrumbs__list">
5
+ {% for crumb in breadcrumbs %}
6
+ <li class="breadcrumbs__item">
7
+ {% if crumb.href %}
8
+ <a href="{{ crumb.href }}" class="breadcrumbs__link">{{ crumb.text }}</a>
9
+ {% else %}
10
+ <span class="breadcrumbs__current" aria-current="page">{{ crumb.text }}</span>
11
+ {% endif %}
12
+ </li>
13
+ {% endfor %}
14
+ </ol>
15
+ </nav>
16
+ {% endif %}
@@ -202,6 +202,7 @@
202
202
  class="item-actions__button item-actions__mark-read"
203
203
  data-action="mark-read"
204
204
  data-item-id="{{ item._id }}"
205
+ {% if item._channelUid %}data-channel-uid="{{ item._channelUid }}"{% endif %}
205
206
  title="Mark as read">
206
207
  {{ icon("checkboxChecked") }}
207
208
  <span class="visually-hidden">Mark read</span>
@@ -31,13 +31,13 @@
31
31
  {% if items.length > 0 %}
32
32
  <div class="timeline" id="timeline">
33
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" %}
34
+ <div class="timeline-view__item">
36
35
  {% if item._channelName %}
37
- <span class="timeline-view__channel-label" style="color: {{ item._channelColor or '#888' }}">
36
+ <span class="timeline-view__channel-badge" style="background: {{ item._channelColor or '#888' }}">
38
37
  {{ item._channelName }}
39
38
  </span>
40
39
  {% endif %}
40
+ {% include "partials/item-card.njk" %}
41
41
  </div>
42
42
  {% endfor %}
43
43
  </div>
@@ -95,6 +95,61 @@
95
95
  break;
96
96
  }
97
97
  });
98
+
99
+ // Handle individual mark-read buttons
100
+ const microsubApiUrl = '{{ baseUrl }}'.replace(/\/reader$/, '');
101
+
102
+ timeline.addEventListener('click', async (e) => {
103
+ const button = e.target.closest('.item-actions__mark-read');
104
+ if (!button) return;
105
+
106
+ e.preventDefault();
107
+ e.stopPropagation();
108
+
109
+ const itemId = button.dataset.itemId;
110
+ const channelUid = button.dataset.channelUid;
111
+ if (!itemId || !channelUid) return;
112
+
113
+ button.disabled = true;
114
+
115
+ try {
116
+ const formData = new URLSearchParams();
117
+ formData.append('action', 'timeline');
118
+ formData.append('method', 'mark_read');
119
+ formData.append('channel', channelUid);
120
+ formData.append('entry', itemId);
121
+
122
+ const response = await fetch(microsubApiUrl, {
123
+ method: 'POST',
124
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
125
+ body: formData.toString(),
126
+ credentials: 'same-origin'
127
+ });
128
+
129
+ if (response.ok) {
130
+ const card = button.closest('.item-card');
131
+ if (card) {
132
+ card.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
133
+ card.style.opacity = '0';
134
+ card.style.transform = 'translateX(-20px)';
135
+ setTimeout(() => {
136
+ const wrapper = card.closest('.timeline-view__item');
137
+ if (wrapper) wrapper.remove();
138
+ else card.remove();
139
+ if (timeline.querySelectorAll('.item-card').length === 0) {
140
+ location.reload();
141
+ }
142
+ }, 300);
143
+ }
144
+ } else {
145
+ console.error('Failed to mark item as read');
146
+ button.disabled = false;
147
+ }
148
+ } catch (error) {
149
+ console.error('Error marking item as read:', error);
150
+ button.disabled = false;
151
+ }
152
+ });
98
153
  }
99
154
  </script>
100
155
  {% endblock %}