@rmdes/indiekit-endpoint-activitypub 2.4.4 → 2.5.0

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.
@@ -1,121 +1,55 @@
1
1
  /**
2
- * Infinite scroll — AlpineJS component for AJAX load-more on the timeline
3
- * Registers the `apInfiniteScroll` Alpine data component.
2
+ * Infinite scroll — unified AlpineJS component for AJAX load-more.
3
+ * Works for both reader timeline and explore view via data attributes.
4
+ *
5
+ * Required data attributes on the component element:
6
+ * data-cursor — initial pagination cursor value
7
+ * data-api-url — API endpoint URL (e.g., /activitypub/admin/reader/api/timeline)
8
+ * data-cursor-param — query param name for the cursor (e.g., "before" or "max_id")
9
+ * data-cursor-field — response JSON field for the next cursor (e.g., "before" or "maxId")
10
+ * data-timeline-id — DOM ID of the timeline container to append HTML into
11
+ *
12
+ * Optional:
13
+ * data-extra-params — JSON-encoded object of additional query params
14
+ * data-hide-pagination — CSS selector of no-JS pagination to hide
4
15
  */
5
16
 
6
17
  document.addEventListener("alpine:init", () => {
7
18
  // eslint-disable-next-line no-undef
8
- Alpine.data("apExploreScroll", () => ({
19
+ Alpine.data("apInfiniteScroll", () => ({
9
20
  loading: false,
10
21
  done: false,
11
- maxId: null,
12
- instance: "",
13
- scope: "local",
14
- hashtag: "",
22
+ cursor: null,
23
+ apiUrl: "",
24
+ cursorParam: "before",
25
+ cursorField: "before",
26
+ timelineId: "",
27
+ extraParams: {},
15
28
  observer: null,
16
29
 
17
30
  init() {
18
31
  const el = this.$el;
19
- this.maxId = el.dataset.maxId || null;
20
- this.instance = el.dataset.instance || "";
21
- this.scope = el.dataset.scope || "local";
22
- this.hashtag = el.dataset.hashtag || "";
23
-
24
- if (!this.maxId) {
25
- this.done = true;
26
- return;
27
- }
28
-
29
- this.observer = new IntersectionObserver(
30
- (entries) => {
31
- for (const entry of entries) {
32
- if (entry.isIntersecting && !this.loading && !this.done) {
33
- this.loadMore();
34
- }
35
- }
36
- },
37
- { rootMargin: "200px" }
38
- );
39
-
40
- if (this.$refs.sentinel) {
41
- this.observer.observe(this.$refs.sentinel);
42
- }
43
- },
44
-
45
- async loadMore() {
46
- if (this.loading || this.done || !this.maxId) return;
47
-
48
- this.loading = true;
49
-
50
- const timeline = document.getElementById("ap-explore-timeline");
51
- const mountPath = timeline ? timeline.dataset.mountPath : "";
52
-
53
- const params = new URLSearchParams({
54
- instance: this.instance,
55
- scope: this.scope,
56
- max_id: this.maxId,
57
- });
58
- // Pass hashtag when in hashtag mode so infinite scroll stays on tag timeline
59
- if (this.hashtag) {
60
- params.set("hashtag", this.hashtag);
61
- }
32
+ this.cursor = el.dataset.cursor || null;
33
+ this.apiUrl = el.dataset.apiUrl || "";
34
+ this.cursorParam = el.dataset.cursorParam || "before";
35
+ this.cursorField = el.dataset.cursorField || "before";
36
+ this.timelineId = el.dataset.timelineId || "";
62
37
 
38
+ // Parse extra params from JSON data attribute
63
39
  try {
64
- const res = await fetch(
65
- `${mountPath}/admin/reader/api/explore?${params.toString()}`,
66
- { headers: { Accept: "application/json" } }
67
- );
68
-
69
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
70
-
71
- const data = await res.json();
72
-
73
- if (data.html && timeline) {
74
- timeline.insertAdjacentHTML("beforeend", data.html);
75
- }
76
-
77
- if (data.maxId) {
78
- this.maxId = data.maxId;
79
- } else {
80
- this.done = true;
81
- if (this.observer) this.observer.disconnect();
82
- }
83
- } catch (err) {
84
- console.error("[ap-explore-scroll] load failed:", err.message);
85
- } finally {
86
- this.loading = false;
40
+ this.extraParams = JSON.parse(el.dataset.extraParams || "{}");
41
+ } catch {
42
+ this.extraParams = {};
87
43
  }
88
- },
89
-
90
- destroy() {
91
- if (this.observer) this.observer.disconnect();
92
- },
93
- }));
94
-
95
- // eslint-disable-next-line no-undef
96
- Alpine.data("apInfiniteScroll", () => ({
97
- loading: false,
98
- done: false,
99
- before: null,
100
- tab: "",
101
- tag: "",
102
- observer: null,
103
-
104
- init() {
105
- const el = this.$el;
106
- this.before = el.dataset.before || null;
107
- this.tab = el.dataset.tab || "";
108
- this.tag = el.dataset.tag || "";
109
44
 
110
45
  // Hide the no-JS pagination fallback now that JS is active
111
- const paginationEl =
112
- document.getElementById("ap-reader-pagination") ||
113
- document.getElementById("ap-tag-pagination");
114
- if (paginationEl) {
115
- paginationEl.style.display = "none";
46
+ const hideSel = el.dataset.hidePagination;
47
+ if (hideSel) {
48
+ const paginationEl = document.getElementById(hideSel);
49
+ if (paginationEl) paginationEl.style.display = "none";
116
50
  }
117
51
 
118
- if (!this.before) {
52
+ if (!this.cursor) {
119
53
  this.done = true;
120
54
  return;
121
55
  }
@@ -129,7 +63,7 @@ document.addEventListener("alpine:init", () => {
129
63
  }
130
64
  }
131
65
  },
132
- { rootMargin: "200px" }
66
+ { rootMargin: "200px" },
133
67
  );
134
68
 
135
69
  if (this.$refs.sentinel) {
@@ -138,36 +72,36 @@ document.addEventListener("alpine:init", () => {
138
72
  },
139
73
 
140
74
  async loadMore() {
141
- if (this.loading || this.done || !this.before) return;
75
+ if (this.loading || this.done || !this.cursor) return;
142
76
 
143
77
  this.loading = true;
144
78
 
145
- const timeline = document.getElementById("ap-timeline");
146
- const mountPath = timeline ? timeline.dataset.mountPath : "";
147
-
148
- const params = new URLSearchParams({ before: this.before });
149
- if (this.tab) params.set("tab", this.tab);
150
- if (this.tag) params.set("tag", this.tag);
79
+ const params = new URLSearchParams({
80
+ [this.cursorParam]: this.cursor,
81
+ ...this.extraParams,
82
+ });
151
83
 
152
84
  try {
153
85
  const res = await fetch(
154
- `${mountPath}/admin/reader/api/timeline?${params.toString()}`,
155
- { headers: { Accept: "application/json" } }
86
+ `${this.apiUrl}?${params.toString()}`,
87
+ { headers: { Accept: "application/json" } },
156
88
  );
157
89
 
158
90
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
159
91
 
160
92
  const data = await res.json();
161
93
 
94
+ const timeline = this.timelineId
95
+ ? document.getElementById(this.timelineId)
96
+ : null;
97
+
162
98
  if (data.html && timeline) {
163
- // Append the returned pre-rendered HTML
164
99
  timeline.insertAdjacentHTML("beforeend", data.html);
165
100
  }
166
101
 
167
- if (data.before) {
168
- this.before = data.before;
102
+ if (data[this.cursorField]) {
103
+ this.cursor = data[this.cursorField];
169
104
  } else {
170
- // No more items
171
105
  this.done = true;
172
106
  if (this.observer) this.observer.disconnect();
173
107
  }
@@ -178,10 +112,6 @@ document.addEventListener("alpine:init", () => {
178
112
  }
179
113
  },
180
114
 
181
- appendItems(/* detail */) {
182
- // Custom event hook — not used in this implementation but kept for extensibility
183
- },
184
-
185
115
  destroy() {
186
116
  if (this.observer) this.observer.disconnect();
187
117
  },
@@ -366,6 +366,16 @@ document.addEventListener("alpine:init", () => {
366
366
  }
367
367
  },
368
368
 
369
+ // ── Public load-more method (called by button click) ────────────────────
370
+
371
+ loadMoreTab(tab) {
372
+ if (tab.type === "instance") {
373
+ this._loadMoreInstanceTab(tab);
374
+ } else if (tab.type === "hashtag") {
375
+ this._loadMoreHashtagTab(tab);
376
+ }
377
+ },
378
+
369
379
  // ── Infinite scroll for tab panels ───────────────────────────────────────
370
380
 
371
381
  _setupScrollObserver(tab) {
package/assets/reader.css CHANGED
@@ -2486,15 +2486,21 @@
2486
2486
  }
2487
2487
 
2488
2488
  .ap-quote-embed__content {
2489
- -webkit-box-orient: vertical;
2490
- -webkit-line-clamp: 6;
2491
2489
  color: var(--color-on-background);
2492
- display: -webkit-box;
2493
2490
  font-size: var(--font-size-s);
2494
2491
  line-height: 1.5;
2492
+ max-height: calc(1.5em * 6);
2495
2493
  overflow: hidden;
2496
2494
  }
2497
2495
 
2496
+ .ap-quote-embed__content a {
2497
+ display: inline;
2498
+ }
2499
+
2500
+ .ap-quote-embed__content a span {
2501
+ display: inline;
2502
+ }
2503
+
2498
2504
  .ap-quote-embed__content p {
2499
2505
  margin: 0 0 var(--space-xs);
2500
2506
  }
@@ -4,13 +4,7 @@
4
4
 
5
5
  import { getTimelineItems, countNewItems, markItemsRead } from "../storage/timeline.js";
6
6
  import { getToken, validateToken } from "../csrf.js";
7
- import {
8
- getMutedUrls,
9
- getMutedKeywords,
10
- getBlockedUrls,
11
- getFilterMode,
12
- } from "../storage/moderation.js";
13
- import { stripQuoteReferenceHtml } from "../og-unfurl.js";
7
+ import { postProcessItems, applyTabFilter, loadModerationData, renderItemCards } from "../item-processing.js";
14
8
 
15
9
  export function apiTimelineController(mountPath) {
16
10
  return async (request, response, next) => {
@@ -26,7 +20,7 @@ export function apiTimelineController(mountPath) {
26
20
  const before = request.query.before;
27
21
  const limit = 20;
28
22
 
29
- // Build storage query options (same logic as readerController)
23
+ // Build storage query options
30
24
  const unread = request.query.unread === "1";
31
25
  const options = { before, limit, unread };
32
26
 
@@ -45,132 +39,32 @@ export function apiTimelineController(mountPath) {
45
39
 
46
40
  const result = await getTimelineItems(collections, options);
47
41
 
48
- // Client-side tab filtering for types not supported by storage
49
- let items = result.items;
50
- if (!tag) {
51
- if (tab === "replies") {
52
- items = items.filter((item) => item.inReplyTo);
53
- } else if (tab === "media") {
54
- items = items.filter(
55
- (item) =>
56
- (item.photo && item.photo.length > 0) ||
57
- (item.video && item.video.length > 0) ||
58
- (item.audio && item.audio.length > 0)
59
- );
60
- }
61
- }
42
+ // Tab filtering for types not supported by storage layer
43
+ const tabFiltered = tag ? result.items : applyTabFilter(result.items, tab);
62
44
 
63
- // Apply moderation filters
45
+ // Shared processing pipeline: moderation, quote stripping, interactions
64
46
  const modCollections = {
65
47
  ap_muted: application?.collections?.get("ap_muted"),
66
48
  ap_blocked: application?.collections?.get("ap_blocked"),
67
49
  ap_profile: application?.collections?.get("ap_profile"),
68
50
  };
69
- const [mutedUrls, mutedKeywords, blockedUrls, filterMode] =
70
- await Promise.all([
71
- getMutedUrls(modCollections),
72
- getMutedKeywords(modCollections),
73
- getBlockedUrls(modCollections),
74
- getFilterMode(modCollections),
75
- ]);
76
- const blockedSet = new Set(blockedUrls);
77
- const mutedSet = new Set(mutedUrls);
78
-
79
- if (blockedSet.size > 0 || mutedSet.size > 0 || mutedKeywords.length > 0) {
80
- items = items.filter((item) => {
81
- if (item.author?.url && blockedSet.has(item.author.url)) {
82
- return false;
83
- }
84
-
85
- const isMutedActor = item.author?.url && mutedSet.has(item.author.url);
86
- let matchedKeyword = null;
87
- if (mutedKeywords.length > 0) {
88
- const searchable = [item.content?.text, item.name, item.summary]
89
- .filter(Boolean)
90
- .join(" ")
91
- .toLowerCase();
92
- if (searchable) {
93
- matchedKeyword = mutedKeywords.find((kw) =>
94
- searchable.includes(kw.toLowerCase())
95
- );
96
- }
97
- }
98
-
99
- if (isMutedActor || matchedKeyword) {
100
- if (filterMode === "warn") {
101
- item._moderated = true;
102
- item._moderationReason = isMutedActor ? "muted_account" : "muted_keyword";
103
- if (matchedKeyword) item._moderationKeyword = matchedKeyword;
104
- return true;
105
- }
106
- return false;
107
- }
108
-
109
- return true;
110
- });
111
- }
112
-
113
- // Get interaction state
114
- const interactionsCol = application?.collections?.get("ap_interactions");
115
- const interactionMap = {};
116
-
117
- if (interactionsCol) {
118
- const lookupUrls = new Set();
119
- const objectUrlToUid = new Map();
120
- for (const item of items) {
121
- const uid = item.uid;
122
- const displayUrl = item.url || item.originalUrl;
123
- if (uid) { lookupUrls.add(uid); objectUrlToUid.set(uid, uid); }
124
- if (displayUrl) { lookupUrls.add(displayUrl); objectUrlToUid.set(displayUrl, uid || displayUrl); }
125
- }
126
- if (lookupUrls.size > 0) {
127
- const interactions = await interactionsCol
128
- .find({ objectUrl: { $in: [...lookupUrls] } })
129
- .toArray();
130
- for (const interaction of interactions) {
131
- const key = objectUrlToUid.get(interaction.objectUrl) || interaction.objectUrl;
132
- if (!interactionMap[key]) interactionMap[key] = {};
133
- interactionMap[key][interaction.type] = true;
134
- }
135
- }
136
- }
51
+ const moderation = await loadModerationData(modCollections);
137
52
 
138
- // Strip "RE:" paragraphs from items that have quote embeds
139
- for (const item of items) {
140
- const quoteRef = item.quoteUrl || item.quote?.url || item.quote?.uid;
141
- if (item.quote && quoteRef && item.content?.html) {
142
- item.content.html = stripQuoteReferenceHtml(item.content.html, quoteRef);
143
- }
144
- }
53
+ const { items, interactionMap } = await postProcessItems(tabFiltered, {
54
+ moderation,
55
+ interactionsCol: application?.collections?.get("ap_interactions"),
56
+ });
145
57
 
146
58
  const csrfToken = getToken(request.session);
147
-
148
- // Render each card server-side using the same Nunjucks template
149
- // Merge response.locals so that i18n (__), mountPath, etc. are available
150
- const templateData = {
59
+ const html = await renderItemCards(items, request, {
151
60
  ...response.locals,
152
61
  mountPath,
153
62
  csrfToken,
154
63
  interactionMap,
155
- };
156
-
157
- const htmlParts = await Promise.all(
158
- items.map((item) => {
159
- return new Promise((resolve, reject) => {
160
- request.app.render(
161
- "partials/ap-item-card.njk",
162
- { ...templateData, item },
163
- (err, html) => {
164
- if (err) reject(err);
165
- else resolve(html);
166
- }
167
- );
168
- });
169
- })
170
- );
64
+ });
171
65
 
172
66
  response.json({
173
- html: htmlParts.join(""),
67
+ html,
174
68
  before: result.before,
175
69
  });
176
70
  } catch (error) {
@@ -8,7 +8,7 @@
8
8
  import { searchInstances, checkInstanceTimeline, getPopularAccounts } from "../fedidb.js";
9
9
  import { getToken } from "../csrf.js";
10
10
  import { validateInstance, validateHashtag, mapMastodonStatusToItem } from "./explore-utils.js";
11
- import { stripQuoteReferenceHtml } from "../og-unfurl.js";
11
+ import { postProcessItems, renderItemCards } from "../item-processing.js";
12
12
 
13
13
  const FETCH_TIMEOUT_MS = 10_000;
14
14
  const MAX_RESULTS = 20;
@@ -16,6 +16,57 @@ const MAX_RESULTS = 20;
16
16
  // Re-export validateInstance for backward compatibility (used by tabs.js, index.js)
17
17
  export { validateInstance } from "./explore-utils.js";
18
18
 
19
+ /**
20
+ * Fetch statuses from a remote Mastodon-compatible instance.
21
+ *
22
+ * @param {string} instance - Validated hostname
23
+ * @param {object} options
24
+ * @param {string} [options.scope] - "local" or "federated"
25
+ * @param {string} [options.hashtag] - Validated hashtag (no #)
26
+ * @param {string} [options.maxId] - Pagination cursor
27
+ * @param {number} [options.limit] - Max results
28
+ * @returns {Promise<{ items: Array, nextMaxId: string|null }>}
29
+ */
30
+ export async function fetchMastodonTimeline(instance, { scope = "local", hashtag, maxId, limit = MAX_RESULTS } = {}) {
31
+ const isLocal = scope === "local";
32
+ let apiUrl;
33
+ if (hashtag) {
34
+ apiUrl = new URL(`https://${instance}/api/v1/timelines/tag/${encodeURIComponent(hashtag)}`);
35
+ } else {
36
+ apiUrl = new URL(`https://${instance}/api/v1/timelines/public`);
37
+ }
38
+ apiUrl.searchParams.set("local", isLocal ? "true" : "false");
39
+ apiUrl.searchParams.set("limit", String(limit));
40
+ if (maxId) apiUrl.searchParams.set("max_id", maxId);
41
+
42
+ const controller = new AbortController();
43
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
44
+
45
+ const fetchRes = await fetch(apiUrl.toString(), {
46
+ headers: { Accept: "application/json" },
47
+ signal: controller.signal,
48
+ });
49
+ clearTimeout(timeoutId);
50
+
51
+ if (!fetchRes.ok) {
52
+ throw new Error(`Remote instance returned HTTP ${fetchRes.status}`);
53
+ }
54
+
55
+ const statuses = await fetchRes.json();
56
+ if (!Array.isArray(statuses)) {
57
+ throw new Error("Unexpected API response format");
58
+ }
59
+
60
+ const items = statuses.map((s) => mapMastodonStatusToItem(s, instance));
61
+
62
+ const nextMaxId =
63
+ statuses.length === limit && statuses.length > 0
64
+ ? statuses[statuses.length - 1].id || null
65
+ : null;
66
+
67
+ return { items, nextMaxId };
68
+ }
69
+
19
70
  export function exploreController(mountPath) {
20
71
  return async (request, response, next) => {
21
72
  try {
@@ -60,58 +111,15 @@ export function exploreController(mountPath) {
60
111
  });
61
112
  }
62
113
 
63
- // Build API URL: hashtag timeline or public timeline
64
- const isLocal = scope === "local";
65
- let apiUrl;
66
- if (hashtag) {
67
- apiUrl = new URL(`https://${instance}/api/v1/timelines/tag/${encodeURIComponent(hashtag)}`);
68
- apiUrl.searchParams.set("local", isLocal ? "true" : "false");
69
- } else {
70
- apiUrl = new URL(`https://${instance}/api/v1/timelines/public`);
71
- apiUrl.searchParams.set("local", isLocal ? "true" : "false");
72
- }
73
- apiUrl.searchParams.set("limit", String(MAX_RESULTS));
74
- if (maxId) apiUrl.searchParams.set("max_id", maxId);
75
-
76
114
  let items = [];
77
115
  let nextMaxId = null;
78
116
  let error = null;
79
117
 
80
118
  try {
81
- const controller = new AbortController();
82
- const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
83
-
84
- const fetchRes = await fetch(apiUrl.toString(), {
85
- headers: { Accept: "application/json" },
86
- signal: controller.signal,
87
- });
88
- clearTimeout(timeoutId);
89
-
90
- if (!fetchRes.ok) {
91
- throw new Error(`Remote instance returned HTTP ${fetchRes.status}`);
92
- }
93
-
94
- const statuses = await fetchRes.json();
95
-
96
- if (!Array.isArray(statuses)) {
97
- throw new Error("Unexpected API response format");
98
- }
99
-
100
- items = statuses.map((s) => mapMastodonStatusToItem(s, instance));
101
-
102
- // Strip "RE:" paragraphs from items that have quote embeds
103
- for (const item of items) {
104
- const quoteRef = item.quoteUrl || item.quote?.url || item.quote?.uid;
105
- if (item.quote && quoteRef && item.content?.html) {
106
- item.content.html = stripQuoteReferenceHtml(item.content.html, quoteRef);
107
- }
108
- }
109
-
110
- // Get next max_id from last item for pagination
111
- if (statuses.length === MAX_RESULTS && statuses.length > 0) {
112
- const last = statuses[statuses.length - 1];
113
- nextMaxId = last.id || null;
114
- }
119
+ const result = await fetchMastodonTimeline(instance, { scope, hashtag, maxId });
120
+ const processed = await postProcessItems(result.items);
121
+ items = processed.items;
122
+ nextMaxId = result.nextMaxId;
115
123
  } catch (fetchError) {
116
124
  const msg = fetchError.name === "AbortError"
117
125
  ? response.locals.__("activitypub.reader.explore.timeout")
@@ -130,7 +138,6 @@ export function exploreController(mountPath) {
130
138
  error,
131
139
  mountPath,
132
140
  csrfToken,
133
- // Pass empty interactionMap — explore posts are not in our DB
134
141
  interactionMap: {},
135
142
  });
136
143
  } catch (error) {
@@ -157,81 +164,18 @@ export function exploreApiController(mountPath) {
157
164
  return response.status(400).json({ error: "Invalid instance" });
158
165
  }
159
166
 
160
- // Build API URL: hashtag timeline or public timeline
161
- const isLocal = scope === "local";
162
- let apiUrl;
163
- if (hashtag) {
164
- apiUrl = new URL(`https://${instance}/api/v1/timelines/tag/${encodeURIComponent(hashtag)}`);
165
- apiUrl.searchParams.set("local", isLocal ? "true" : "false");
166
- } else {
167
- apiUrl = new URL(`https://${instance}/api/v1/timelines/public`);
168
- apiUrl.searchParams.set("local", isLocal ? "true" : "false");
169
- }
170
- apiUrl.searchParams.set("limit", String(MAX_RESULTS));
171
- if (maxId) apiUrl.searchParams.set("max_id", maxId);
172
-
173
- const controller = new AbortController();
174
- const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
167
+ const { items: rawItems, nextMaxId } = await fetchMastodonTimeline(instance, { scope, hashtag, maxId });
168
+ const { items } = await postProcessItems(rawItems);
175
169
 
176
- const fetchRes = await fetch(apiUrl.toString(), {
177
- headers: { Accept: "application/json" },
178
- signal: controller.signal,
179
- });
180
- clearTimeout(timeoutId);
181
-
182
- if (!fetchRes.ok) {
183
- return response.status(502).json({ error: `Remote returned ${fetchRes.status}` });
184
- }
185
-
186
- const statuses = await fetchRes.json();
187
- if (!Array.isArray(statuses)) {
188
- return response.status(502).json({ error: "Unexpected API response" });
189
- }
190
-
191
- const items = statuses.map((s) => mapMastodonStatusToItem(s, instance));
192
-
193
- // Strip "RE:" paragraphs from items that have quote embeds
194
- for (const item of items) {
195
- const quoteRef = item.quoteUrl || item.quote?.url || item.quote?.uid;
196
- if (item.quote && quoteRef && item.content?.html) {
197
- item.content.html = stripQuoteReferenceHtml(item.content.html, quoteRef);
198
- }
199
- }
200
-
201
- let nextMaxId = null;
202
- if (statuses.length === MAX_RESULTS && statuses.length > 0) {
203
- const last = statuses[statuses.length - 1];
204
- nextMaxId = last.id || null;
205
- }
206
-
207
- // Render each card server-side
208
170
  const csrfToken = getToken(request.session);
209
- const templateData = {
171
+ const html = await renderItemCards(items, request, {
210
172
  ...response.locals,
211
173
  mountPath,
212
174
  csrfToken,
213
175
  interactionMap: {},
214
- };
215
-
216
- const htmlParts = await Promise.all(
217
- items.map((item) => {
218
- return new Promise((resolve, reject) => {
219
- request.app.render(
220
- "partials/ap-item-card.njk",
221
- { ...templateData, item },
222
- (err, html) => {
223
- if (err) reject(err);
224
- else resolve(html);
225
- }
226
- );
227
- });
228
- })
229
- );
230
-
231
- response.json({
232
- html: htmlParts.join(""),
233
- maxId: nextMaxId,
234
176
  });
177
+
178
+ response.json({ html, maxId: nextMaxId });
235
179
  } catch (error) {
236
180
  next(error);
237
181
  }