@rmdes/indiekit-endpoint-activitypub 2.0.30 → 2.0.32

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.
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Deck components — Alpine.js components for the TweetDeck-style deck view.
3
+ *
4
+ * Registers:
5
+ * apDeckToggle — star/favorite button to add/remove a deck on the Search tab
6
+ * apDeckColumn — single deck column with its own infinite-scroll timeline
7
+ */
8
+
9
+ document.addEventListener("alpine:init", () => {
10
+ // ── apDeckToggle ──────────────────────────────────────────────────────────
11
+ //
12
+ // Star/favorite button that adds or removes a deck entry for the current
13
+ // instance+scope combination.
14
+ //
15
+ // Parameters (passed via x-data):
16
+ // domain — instance hostname (e.g. "mastodon.social")
17
+ // scope — "local" | "federated"
18
+ // mountPath — plugin mount path for API URL construction
19
+ // csrfToken — CSRF token from server session
20
+ // deckCount — current number of saved decks (for limit enforcement)
21
+ // initialState — true if this instance+scope is already a deck
22
+ // eslint-disable-next-line no-undef
23
+ Alpine.data("apDeckToggle", (domain, scope, mountPath, csrfToken, deckCount, initialState) => ({
24
+ inDeck: initialState,
25
+ currentCount: deckCount,
26
+ loading: false,
27
+
28
+ get deckLimitReached() {
29
+ return this.currentCount >= 8 && !this.inDeck;
30
+ },
31
+
32
+ async toggle() {
33
+ if (this.loading) return;
34
+ if (!this.inDeck && this.deckLimitReached) return;
35
+
36
+ this.loading = true;
37
+ try {
38
+ const url = this.inDeck
39
+ ? `${mountPath}/admin/reader/api/decks/remove`
40
+ : `${mountPath}/admin/reader/api/decks`;
41
+
42
+ const res = await fetch(url, {
43
+ method: "POST",
44
+ headers: {
45
+ "Content-Type": "application/json",
46
+ "X-CSRF-Token": csrfToken,
47
+ },
48
+ body: JSON.stringify({ domain, scope }),
49
+ });
50
+
51
+ if (res.ok) {
52
+ this.inDeck = !this.inDeck;
53
+ // Track actual count so deckLimitReached stays accurate
54
+ this.currentCount += this.inDeck ? 1 : -1;
55
+ }
56
+ } catch {
57
+ // Network error — state unchanged, server is source of truth
58
+ } finally {
59
+ this.loading = false;
60
+ }
61
+ },
62
+ }));
63
+
64
+ // ── apDeckColumn ─────────────────────────────────────────────────────────
65
+ //
66
+ // Individual deck column component. Fetches timeline from the explore API
67
+ // and renders it in a scrollable column with infinite scroll.
68
+ //
69
+ // Uses its own IntersectionObserver referencing `this.$refs.sentinel`
70
+ // (NOT apExploreScroll which hardcodes document.getElementById).
71
+ //
72
+ // Parameters (passed via x-data):
73
+ // domain — instance hostname
74
+ // scope — "local" | "federated"
75
+ // mountPath — plugin mount path
76
+ // index — column position (0-based), used for staggered loading delay
77
+ // csrfToken — CSRF token for remove calls
78
+ // eslint-disable-next-line no-undef
79
+ Alpine.data("apDeckColumn", (domain, scope, mountPath, index, csrfToken) => ({
80
+ itemCount: 0,
81
+ html: "",
82
+ maxId: null,
83
+ loading: false,
84
+ done: false,
85
+ error: null,
86
+ observer: null,
87
+ abortController: null,
88
+
89
+ init() {
90
+ // Stagger initial fetch: column 0 loads immediately, column N waits N*200ms
91
+ const delay = index * 200;
92
+ if (delay === 0) {
93
+ this.loadMore();
94
+ } else {
95
+ setTimeout(() => {
96
+ this.loadMore();
97
+ }, delay);
98
+ }
99
+
100
+ // Set up IntersectionObserver scoped to this column's scrollable body
101
+ // (root must be the scroll container, not viewport, to avoid premature triggers)
102
+ this.$nextTick(() => {
103
+ const root = this.$refs.body || null;
104
+ this.observer = new IntersectionObserver(
105
+ (entries) => {
106
+ for (const entry of entries) {
107
+ if (entry.isIntersecting && !this.loading && !this.done && this.itemCount > 0) {
108
+ this.loadMore();
109
+ }
110
+ }
111
+ },
112
+ { root, rootMargin: "200px" },
113
+ );
114
+
115
+ if (this.$refs.sentinel) {
116
+ this.observer.observe(this.$refs.sentinel);
117
+ }
118
+ });
119
+ },
120
+
121
+ destroy() {
122
+ if (this.abortController) {
123
+ this.abortController.abort();
124
+ this.abortController = null;
125
+ }
126
+
127
+ if (this.observer) {
128
+ this.observer.disconnect();
129
+ this.observer = null;
130
+ }
131
+ },
132
+
133
+ async loadMore() {
134
+ if (this.loading || this.done) return;
135
+
136
+ this.loading = true;
137
+ this.error = null;
138
+
139
+ try {
140
+ this.abortController = new AbortController();
141
+
142
+ const url = new URL(`${mountPath}/admin/reader/api/explore`, window.location.origin);
143
+ url.searchParams.set("instance", domain);
144
+ url.searchParams.set("scope", scope);
145
+ if (this.maxId) url.searchParams.set("max_id", this.maxId);
146
+
147
+ const res = await fetch(url.toString(), {
148
+ headers: { Accept: "application/json" },
149
+ signal: this.abortController.signal,
150
+ });
151
+
152
+ if (!res.ok) {
153
+ throw new Error(`HTTP ${res.status}`);
154
+ }
155
+
156
+ const data = await res.json();
157
+
158
+ if (data.html && data.html.trim() !== "") {
159
+ this.html += data.html;
160
+ this.itemCount++;
161
+ }
162
+
163
+ if (data.maxId) {
164
+ this.maxId = data.maxId;
165
+ } else {
166
+ this.done = true;
167
+ }
168
+
169
+ // If no content came back on first load, mark as done
170
+ if (!data.html || data.html.trim() === "") {
171
+ this.done = true;
172
+ }
173
+ } catch (fetchError) {
174
+ this.error = fetchError.message || "Could not load timeline";
175
+ } finally {
176
+ this.loading = false;
177
+ }
178
+ },
179
+
180
+ async retryLoad() {
181
+ this.error = null;
182
+ this.done = false;
183
+ await this.loadMore();
184
+ },
185
+
186
+ async removeDeck() {
187
+ try {
188
+ const res = await fetch(`${mountPath}/admin/reader/api/decks/remove`, {
189
+ method: "POST",
190
+ headers: {
191
+ "Content-Type": "application/json",
192
+ "X-CSRF-Token": csrfToken,
193
+ },
194
+ body: JSON.stringify({ domain, scope }),
195
+ });
196
+
197
+ if (res.ok) {
198
+ // Remove column from DOM
199
+ if (this.observer) {
200
+ this.observer.disconnect();
201
+ }
202
+
203
+ this.$el.remove();
204
+ } else {
205
+ this.error = `Failed to remove (${res.status})`;
206
+ }
207
+ } catch {
208
+ this.error = "Network error — could not remove column";
209
+ }
210
+ },
211
+ }));
212
+ });
package/assets/reader.css CHANGED
@@ -2022,3 +2022,190 @@
2022
2022
  color: var(--color-on-offset);
2023
2023
  font-weight: 600;
2024
2024
  }
2025
+
2026
+ /* ---------- Explore: deck toggle button ---------- */
2027
+
2028
+ .ap-explore-deck-toggle {
2029
+ display: flex;
2030
+ justify-content: flex-end;
2031
+ margin-bottom: var(--space-s);
2032
+ }
2033
+
2034
+ .ap-explore-deck-toggle__btn {
2035
+ align-items: center;
2036
+ background: none;
2037
+ border: var(--border-width-thin) solid var(--color-outline);
2038
+ border-radius: var(--border-radius-small);
2039
+ color: var(--color-on-background);
2040
+ cursor: pointer;
2041
+ display: inline-flex;
2042
+ font-size: var(--font-size-s);
2043
+ gap: var(--space-2xs);
2044
+ padding: var(--space-xs) var(--space-s);
2045
+ transition: background 0.15s, color 0.15s, border-color 0.15s;
2046
+ }
2047
+
2048
+ .ap-explore-deck-toggle__btn:hover:not(:disabled) {
2049
+ background: var(--color-offset);
2050
+ }
2051
+
2052
+ .ap-explore-deck-toggle__btn--active {
2053
+ background: var(--color-accent5);
2054
+ border-color: var(--color-accent5);
2055
+ color: var(--color-on-accent, #fff);
2056
+ }
2057
+
2058
+ .ap-explore-deck-toggle__btn--active:hover:not(:disabled) {
2059
+ background: var(--color-accent45);
2060
+ border-color: var(--color-accent45);
2061
+ }
2062
+
2063
+ .ap-explore-deck-toggle__btn:disabled {
2064
+ cursor: not-allowed;
2065
+ opacity: 0.5;
2066
+ }
2067
+
2068
+ /* ---------- Deck grid layout ---------- */
2069
+
2070
+ .ap-deck-grid {
2071
+ display: grid;
2072
+ gap: var(--space-m);
2073
+ grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
2074
+ margin-top: var(--space-m);
2075
+ min-width: 0;
2076
+ }
2077
+
2078
+ /* ---------- Deck column ---------- */
2079
+
2080
+ .ap-deck-column {
2081
+ background: var(--color-offset);
2082
+ border: var(--border-width-thin) solid var(--color-outline);
2083
+ border-radius: var(--border-radius-small);
2084
+ display: flex;
2085
+ flex-direction: column;
2086
+ max-height: calc(100dvh - 220px);
2087
+ min-height: 200px;
2088
+ min-width: 0;
2089
+ overflow: hidden;
2090
+ }
2091
+
2092
+ .ap-deck-column__header {
2093
+ align-items: center;
2094
+ background: var(--color-background);
2095
+ border-bottom: var(--border-width-thin) solid var(--color-outline);
2096
+ display: flex;
2097
+ flex-shrink: 0;
2098
+ gap: var(--space-xs);
2099
+ padding: var(--space-xs) var(--space-s);
2100
+ }
2101
+
2102
+ .ap-deck-column__domain {
2103
+ font-size: var(--font-size-s);
2104
+ font-weight: 600;
2105
+ min-width: 0;
2106
+ overflow: hidden;
2107
+ text-overflow: ellipsis;
2108
+ white-space: nowrap;
2109
+ }
2110
+
2111
+ .ap-deck-column__scope-badge {
2112
+ border-radius: var(--border-radius-small);
2113
+ flex-shrink: 0;
2114
+ font-size: var(--font-size-xs);
2115
+ font-weight: 600;
2116
+ padding: 2px var(--space-xs);
2117
+ text-transform: uppercase;
2118
+ }
2119
+
2120
+ .ap-deck-column__scope-badge--local {
2121
+ background: var(--color-blue10, #dbeafe);
2122
+ color: var(--color-blue50, #1e40af);
2123
+ }
2124
+
2125
+ .ap-deck-column__scope-badge--federated {
2126
+ background: var(--color-purple10, #ede9fe);
2127
+ color: var(--color-purple50, #5b21b6);
2128
+ }
2129
+
2130
+ .ap-deck-column__remove {
2131
+ background: none;
2132
+ border: none;
2133
+ color: var(--color-on-offset);
2134
+ cursor: pointer;
2135
+ flex-shrink: 0;
2136
+ font-size: 1.2rem;
2137
+ line-height: 1;
2138
+ margin-left: auto;
2139
+ padding: 0 2px;
2140
+ }
2141
+
2142
+ .ap-deck-column__remove:hover {
2143
+ color: var(--color-red45);
2144
+ }
2145
+
2146
+ .ap-deck-column__body {
2147
+ flex: 1;
2148
+ min-height: 0;
2149
+ overflow-y: auto;
2150
+ padding: var(--space-xs);
2151
+ }
2152
+
2153
+ .ap-deck-column__loading,
2154
+ .ap-deck-column__loading-more,
2155
+ .ap-deck-column__error,
2156
+ .ap-deck-column__empty,
2157
+ .ap-deck-column__done {
2158
+ color: var(--color-on-offset);
2159
+ font-size: var(--font-size-s);
2160
+ padding: var(--space-s);
2161
+ text-align: center;
2162
+ }
2163
+
2164
+ .ap-deck-column__retry {
2165
+ background: none;
2166
+ border: var(--border-width-thin) solid var(--color-outline);
2167
+ border-radius: var(--border-radius-small);
2168
+ color: var(--color-on-background);
2169
+ cursor: pointer;
2170
+ font-size: var(--font-size-s);
2171
+ margin-top: var(--space-xs);
2172
+ padding: var(--space-xs) var(--space-s);
2173
+ }
2174
+
2175
+ .ap-deck-column__retry:hover {
2176
+ background: var(--color-offset);
2177
+ }
2178
+
2179
+ /* Cards inside deck columns are more compact */
2180
+ .ap-deck-column__items .ap-item-card {
2181
+ font-size: var(--font-size-s);
2182
+ }
2183
+
2184
+ /* ---------- Deck empty state ---------- */
2185
+
2186
+ .ap-deck-empty {
2187
+ margin-top: var(--space-xl);
2188
+ text-align: center;
2189
+ }
2190
+
2191
+ .ap-deck-empty p {
2192
+ color: var(--color-on-offset);
2193
+ font-size: var(--font-size-s);
2194
+ margin-bottom: var(--space-s);
2195
+ }
2196
+
2197
+ .ap-deck-empty__link {
2198
+ font-size: var(--font-size-s);
2199
+ }
2200
+
2201
+ /* ---------- Deck responsive ---------- */
2202
+
2203
+ @media (max-width: 767px) {
2204
+ .ap-deck-grid {
2205
+ grid-template-columns: 1fr;
2206
+ }
2207
+
2208
+ .ap-deck-column {
2209
+ max-height: 60vh;
2210
+ }
2211
+ }
package/index.js CHANGED
@@ -69,6 +69,11 @@ import {
69
69
  popularAccountsApiController,
70
70
  } from "./lib/controllers/explore.js";
71
71
  import { followTagController, unfollowTagController } from "./lib/controllers/follow-tag.js";
72
+ import {
73
+ listDecksController,
74
+ addDeckController,
75
+ removeDeckController,
76
+ } from "./lib/controllers/decks.js";
72
77
  import { publicProfileController } from "./lib/controllers/public-profile.js";
73
78
  import { authorizeInteractionController } from "./lib/controllers/authorize-interaction.js";
74
79
  import { myProfileController } from "./lib/controllers/my-profile.js";
@@ -236,6 +241,9 @@ export default class ActivityPubEndpoint {
236
241
  router.get("/admin/reader/api/instances", instanceSearchApiController(mp));
237
242
  router.get("/admin/reader/api/instance-check", instanceCheckApiController(mp));
238
243
  router.get("/admin/reader/api/popular-accounts", popularAccountsApiController(mp));
244
+ router.get("/admin/reader/api/decks", listDecksController(mp));
245
+ router.post("/admin/reader/api/decks", addDeckController(mp));
246
+ router.post("/admin/reader/api/decks/remove", removeDeckController(mp));
239
247
  router.post("/admin/reader/follow-tag", followTagController(mp));
240
248
  router.post("/admin/reader/unfollow-tag", unfollowTagController(mp));
241
249
  router.get("/admin/reader/notifications", notificationsController(mp));
@@ -876,6 +884,8 @@ export default class ActivityPubEndpoint {
876
884
  Indiekit.addCollection("ap_interactions");
877
885
  Indiekit.addCollection("ap_notes");
878
886
  Indiekit.addCollection("ap_followed_tags");
887
+ // Deck collections
888
+ Indiekit.addCollection("ap_decks");
879
889
 
880
890
  // Store collection references (posts resolved lazily)
881
891
  const indiekitCollections = Indiekit.collections;
@@ -896,6 +906,8 @@ export default class ActivityPubEndpoint {
896
906
  ap_interactions: indiekitCollections.get("ap_interactions"),
897
907
  ap_notes: indiekitCollections.get("ap_notes"),
898
908
  ap_followed_tags: indiekitCollections.get("ap_followed_tags"),
909
+ // Deck collections
910
+ ap_decks: indiekitCollections.get("ap_decks"),
899
911
  get posts() {
900
912
  return indiekitCollections.get("posts");
901
913
  },
@@ -1019,6 +1031,12 @@ export default class ActivityPubEndpoint {
1019
1031
  { category: 1, published: -1 },
1020
1032
  { background: true },
1021
1033
  );
1034
+
1035
+ // Deck index — compound unique ensures same instance can appear at most once per scope
1036
+ this._collections.ap_decks.createIndex(
1037
+ { domain: 1, scope: 1 },
1038
+ { unique: true, background: true },
1039
+ );
1022
1040
  } catch {
1023
1041
  // Index creation failed — collections not yet available.
1024
1042
  // Indexes already exist from previous startups; non-fatal.
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Deck CRUD controller — manages favorited instance deck entries.
3
+ * Stored in the ap_decks MongoDB collection.
4
+ */
5
+
6
+ import { validateToken } from "../csrf.js";
7
+ import { validateInstance } from "./explore.js";
8
+
9
+ const MAX_DECKS = 8;
10
+
11
+ /**
12
+ * GET /admin/reader/api/decks
13
+ * Returns all deck entries sorted by addedAt ascending.
14
+ */
15
+ export function listDecksController(_mountPath) {
16
+ return async (request, response, next) => {
17
+ try {
18
+ const { application } = request.app.locals;
19
+ const collection = application?.collections?.get("ap_decks");
20
+ if (!collection) {
21
+ return response.json([]);
22
+ }
23
+
24
+ const decks = await collection
25
+ .find({}, { projection: { _id: 0 } })
26
+ .sort({ addedAt: 1 })
27
+ .toArray();
28
+
29
+ return response.json(decks);
30
+ } catch (error) {
31
+ return next(error);
32
+ }
33
+ };
34
+ }
35
+
36
+ /**
37
+ * POST /admin/reader/api/decks
38
+ * Adds a new deck entry for the given domain + scope.
39
+ * Body: { domain, scope }
40
+ */
41
+ export function addDeckController(_mountPath) {
42
+ return async (request, response, next) => {
43
+ try {
44
+ // CSRF protection
45
+ if (!validateToken(request)) {
46
+ return response.status(403).json({ error: "Invalid CSRF token" });
47
+ }
48
+
49
+ const { application } = request.app.locals;
50
+ const collection = application?.collections?.get("ap_decks");
51
+ if (!collection) {
52
+ return response.status(500).json({ error: "Deck storage unavailable" });
53
+ }
54
+
55
+ const { domain: rawDomain, scope: rawScope } = request.body;
56
+
57
+ // Validate domain (SSRF prevention)
58
+ const domain = validateInstance(rawDomain);
59
+ if (!domain) {
60
+ return response.status(400).json({ error: "Invalid instance domain" });
61
+ }
62
+
63
+ // Validate scope
64
+ const scope = rawScope === "federated" ? "federated" : "local";
65
+
66
+ // Enforce max deck limit
67
+ const count = await collection.countDocuments();
68
+ if (count >= MAX_DECKS) {
69
+ return response.status(400).json({
70
+ error: `Maximum of ${MAX_DECKS} decks reached`,
71
+ });
72
+ }
73
+
74
+ // Insert (unique index on domain+scope will throw on duplicate)
75
+ const deck = {
76
+ domain,
77
+ scope,
78
+ addedAt: new Date().toISOString(),
79
+ };
80
+
81
+ try {
82
+ await collection.insertOne(deck);
83
+ } catch (insertError) {
84
+ if (insertError.code === 11_000) {
85
+ // Duplicate key — deck already exists
86
+ return response.status(409).json({
87
+ error: "Deck already exists for this domain and scope",
88
+ });
89
+ }
90
+
91
+ throw insertError;
92
+ }
93
+
94
+ return response.status(201).json(deck);
95
+ } catch (error) {
96
+ return next(error);
97
+ }
98
+ };
99
+ }
100
+
101
+ /**
102
+ * POST /admin/reader/api/decks/remove
103
+ * Removes the deck entry for the given domain + scope.
104
+ * Body: { domain, scope }
105
+ */
106
+ export function removeDeckController(_mountPath) {
107
+ return async (request, response, next) => {
108
+ try {
109
+ // CSRF protection
110
+ if (!validateToken(request)) {
111
+ return response.status(403).json({ error: "Invalid CSRF token" });
112
+ }
113
+
114
+ const { application } = request.app.locals;
115
+ const collection = application?.collections?.get("ap_decks");
116
+ if (!collection) {
117
+ return response.status(500).json({ error: "Deck storage unavailable" });
118
+ }
119
+
120
+ const { domain: rawDomain, scope: rawScope } = request.body;
121
+
122
+ // Validate domain (SSRF prevention)
123
+ const domain = validateInstance(rawDomain);
124
+ if (!domain) {
125
+ return response.status(400).json({ error: "Invalid instance domain" });
126
+ }
127
+
128
+ const scope = rawScope === "federated" ? "federated" : "local";
129
+
130
+ await collection.deleteOne({ domain, scope });
131
+
132
+ return response.json({ success: true });
133
+ } catch (error) {
134
+ return next(error);
135
+ }
136
+ };
137
+ }
@@ -8,6 +8,7 @@
8
8
  import sanitizeHtml from "sanitize-html";
9
9
  import { sanitizeContent } from "../timeline-store.js";
10
10
  import { searchInstances, checkInstanceTimeline, getPopularAccounts } from "../fedidb.js";
11
+ import { getToken } from "../csrf.js";
11
12
 
12
13
  const FETCH_TIMEOUT_MS = 10_000;
13
14
  const MAX_RESULTS = 20;
@@ -18,7 +19,7 @@ const MAX_RESULTS = 20;
18
19
  * @param {string} instance - Raw instance parameter from query string
19
20
  * @returns {string|null} Validated hostname or null
20
21
  */
21
- function validateInstance(instance) {
22
+ export function validateInstance(instance) {
22
23
  if (!instance || typeof instance !== "string") return null;
23
24
 
24
25
  try {
@@ -122,6 +123,23 @@ export function exploreController(mountPath) {
122
123
  const rawInstance = request.query.instance || "";
123
124
  const scope = request.query.scope === "federated" ? "federated" : "local";
124
125
  const maxId = request.query.max_id || "";
126
+ const activeTab = request.query.tab === "decks" ? "decks" : "search";
127
+
128
+ // Fetch deck list for both tabs (needed for star button state + deck tab)
129
+ const { application } = request.app.locals;
130
+ const decksCollection = application?.collections?.get("ap_decks");
131
+ let decks = [];
132
+ try {
133
+ decks = await decksCollection
134
+ .find({})
135
+ .sort({ addedAt: 1 })
136
+ .toArray();
137
+ } catch {
138
+ // Collection unavailable — non-fatal, decks defaults to []
139
+ }
140
+
141
+ const csrfToken = getToken(request.session);
142
+ const deckCount = decks.length;
125
143
 
126
144
  // No instance specified — render clean initial page (no error)
127
145
  if (!rawInstance.trim()) {
@@ -133,6 +151,11 @@ export function exploreController(mountPath) {
133
151
  maxId: null,
134
152
  error: null,
135
153
  mountPath,
154
+ activeTab,
155
+ decks,
156
+ deckCount,
157
+ isInDeck: false,
158
+ csrfToken,
136
159
  });
137
160
  }
138
161
 
@@ -146,6 +169,11 @@ export function exploreController(mountPath) {
146
169
  maxId: null,
147
170
  error: response.locals.__("activitypub.reader.explore.invalidInstance"),
148
171
  mountPath,
172
+ activeTab,
173
+ decks,
174
+ deckCount,
175
+ isInDeck: false,
176
+ csrfToken,
149
177
  });
150
178
  }
151
179
 
@@ -194,6 +222,10 @@ export function exploreController(mountPath) {
194
222
  error = msg;
195
223
  }
196
224
 
225
+ const isInDeck = decks.some(
226
+ (d) => d.domain === instance && d.scope === scope,
227
+ );
228
+
197
229
  response.render("activitypub-explore", {
198
230
  title: response.locals.__("activitypub.reader.explore.title"),
199
231
  instance,
@@ -202,9 +234,13 @@ export function exploreController(mountPath) {
202
234
  maxId: nextMaxId,
203
235
  error,
204
236
  mountPath,
237
+ activeTab,
238
+ decks,
239
+ deckCount,
240
+ isInDeck,
241
+ csrfToken,
205
242
  // Pass empty interactionMap — explore posts are not in our DB
206
243
  interactionMap: {},
207
- csrfToken: "",
208
244
  });
209
245
  } catch (error) {
210
246
  next(error);
package/lib/fedidb.js CHANGED
@@ -2,10 +2,13 @@
2
2
  * FediDB API client with MongoDB caching.
3
3
  *
4
4
  * Wraps https://api.fedidb.org/v1/ endpoints:
5
- * - /servers?q=...search known fediverse instances
5
+ * - /servers — cursor-paginated list of known fediverse instances (ranked by size)
6
6
  * - /popular-accounts — top accounts by follower count
7
7
  *
8
- * Responses are cached in ap_kv to avoid hitting the API on every keystroke.
8
+ * NOTE: The /servers endpoint ignores query params (q, search, name) and always
9
+ * returns the same ranked list. We paginate through ~500 servers, cache the full
10
+ * corpus for 24 hours, and filter locally when the user searches.
11
+ *
9
12
  * Cache TTL: 24 hours for both datasets.
10
13
  */
11
14
 
@@ -71,46 +74,90 @@ async function writeToCache(kvCollection, cacheKey, data) {
71
74
  }
72
75
 
73
76
  /**
74
- * Search FediDB for instances matching a query.
75
- * Returns a flat array of { domain, software, description, mau, openRegistration }.
77
+ * Fetch the FediDB server catalogue by paginating through cursor-based results.
78
+ * Cached for 24 hours as a single entry. The API ignores the `q` param and
79
+ * always returns a ranked list, so we collect a large corpus and filter locally.
76
80
  *
77
- * Results are cached per normalized query for 24 hours.
81
+ * Paginates up to MAX_PAGES (13 pages × 40 = ~520 servers), which covers
82
+ * all well-known instances. Results are cached in ap_kv for 24 hours.
78
83
  *
79
84
  * @param {object} kvCollection - MongoDB ap_kv collection
80
- * @param {string} query - Search term (e.g. "mast")
81
- * @param {number} [limit=10] - Max results
82
85
  * @returns {Promise<Array>}
83
86
  */
84
- export async function searchInstances(kvCollection, query, limit = 10) {
85
- const q = (query || "").trim().toLowerCase();
86
- if (!q) return [];
87
+ const MAX_PAGES = 13;
87
88
 
88
- const cacheKey = `fedidb:instances:${q}:${limit}`;
89
+ async function getAllServers(kvCollection) {
90
+ const cacheKey = "fedidb:servers-all";
89
91
  const cached = await getFromCache(kvCollection, cacheKey);
90
92
  if (cached) return cached;
91
93
 
94
+ const results = [];
95
+
92
96
  try {
93
- const url = `${API_BASE}/servers?q=${encodeURIComponent(q)}&limit=${limit}`;
94
- const res = await fetchWithTimeout(url);
95
- if (!res.ok) return [];
97
+ let cursor = null;
98
+
99
+ for (let page = 0; page < MAX_PAGES; page++) {
100
+ let url = `${API_BASE}/servers?limit=40`;
101
+ if (cursor) url += `&cursor=${cursor}`;
102
+
103
+ const res = await fetchWithTimeout(url);
104
+ if (!res.ok) break;
105
+
106
+ const json = await res.json();
107
+ const servers = json.data || [];
108
+ if (servers.length === 0) break;
109
+
110
+ for (const s of servers) {
111
+ results.push({
112
+ domain: s.domain,
113
+ software: s.software?.name || "Unknown",
114
+ description: s.description || "",
115
+ mau: s.stats?.monthly_active_users || 0,
116
+ userCount: s.stats?.user_count || 0,
117
+ openRegistration: s.open_registration || false,
118
+ });
119
+ }
96
120
 
97
- const json = await res.json();
98
- const servers = json.data || [];
99
-
100
- const results = servers.map((s) => ({
101
- domain: s.domain,
102
- software: s.software?.name || "Unknown",
103
- description: s.description || "",
104
- mau: s.stats?.monthly_active_users || 0,
105
- userCount: s.stats?.user_count || 0,
106
- openRegistration: s.open_registration || false,
107
- }));
121
+ cursor = json.meta?.next_cursor;
122
+ if (!cursor) break;
123
+ }
108
124
 
109
- await writeToCache(kvCollection, cacheKey, results);
110
- return results;
125
+ if (results.length > 0) {
126
+ await writeToCache(kvCollection, cacheKey, results);
127
+ }
111
128
  } catch {
112
- return [];
129
+ // Return whatever we collected so far
113
130
  }
131
+
132
+ return results;
133
+ }
134
+
135
+ /**
136
+ * Search FediDB for instances matching a query.
137
+ * Returns a flat array of { domain, software, description, mau, openRegistration }.
138
+ *
139
+ * Fetches the full server list once (cached 24h) and filters by domain/software match.
140
+ * FediDB's /v1/servers endpoint ignores the `q` param and always returns a static
141
+ * ranked list, so server-side filtering is the only way to get relevant results.
142
+ *
143
+ * @param {object} kvCollection - MongoDB ap_kv collection
144
+ * @param {string} query - Search term (e.g. "mast")
145
+ * @param {number} [limit=10] - Max results
146
+ * @returns {Promise<Array>}
147
+ */
148
+ export async function searchInstances(kvCollection, query, limit = 10) {
149
+ const q = (query || "").trim().toLowerCase();
150
+ if (!q) return [];
151
+
152
+ const allServers = await getAllServers(kvCollection);
153
+
154
+ return allServers
155
+ .filter(
156
+ (s) =>
157
+ s.domain.toLowerCase().includes(q) ||
158
+ s.software.toLowerCase().includes(q),
159
+ )
160
+ .slice(0, limit);
114
161
  }
115
162
 
116
163
  /**
package/locales/en.json CHANGED
@@ -239,7 +239,24 @@
239
239
  "invalidInstance": "Invalid instance hostname. Please enter a valid domain name.",
240
240
  "mauLabel": "MAU",
241
241
  "timelineSupported": "Public timeline available",
242
- "timelineUnsupported": "Public timeline not available"
242
+ "timelineUnsupported": "Public timeline not available",
243
+ "tabs": {
244
+ "search": "Search",
245
+ "decks": "Decks"
246
+ },
247
+ "deck": {
248
+ "addToDeck": "Add to deck",
249
+ "removeFromDeck": "Remove from deck",
250
+ "inDeck": "In deck",
251
+ "deckLimitReached": "Maximum of 8 decks reached",
252
+ "localBadge": "Local",
253
+ "federatedBadge": "Federated",
254
+ "removeColumn": "Remove column",
255
+ "retry": "Retry",
256
+ "loadError": "Could not load timeline from this instance.",
257
+ "emptyState": "No decks yet. Browse an instance in the Search tab and click the star to add it.",
258
+ "emptyStateLink": "Go to Search"
259
+ }
243
260
  },
244
261
  "tagTimeline": {
245
262
  "postsTagged": "%d posts",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "2.0.30",
3
+ "version": "2.0.32",
4
4
  "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
5
5
  "keywords": [
6
6
  "indiekit",
@@ -9,6 +9,23 @@
9
9
  <p class="ap-explore-header__desc">{{ __("activitypub.reader.explore.description") }}</p>
10
10
  </header>
11
11
 
12
+ {# Tab navigation #}
13
+ {% set exploreBase = mountPath + "/admin/reader/explore" %}
14
+ <nav class="ap-tabs">
15
+ <a href="{{ exploreBase }}" class="ap-tab{% if activeTab != 'decks' %} ap-tab--active{% endif %}">
16
+ {{ __("activitypub.reader.explore.tabs.search") }}
17
+ </a>
18
+ <a href="{{ exploreBase }}?tab=decks" class="ap-tab{% if activeTab == 'decks' %} ap-tab--active{% endif %}">
19
+ {{ __("activitypub.reader.explore.tabs.decks") }}
20
+ {% if decks and decks.length > 0 %}
21
+ <span class="ap-tab__count">{{ decks.length }}</span>
22
+ {% endif %}
23
+ </a>
24
+ </nav>
25
+
26
+ {# ── Search tab ────────────────────────────────────────────────── #}
27
+ {% if activeTab != 'decks' %}
28
+
12
29
  {# Instance form with autocomplete #}
13
30
  <form action="{{ mountPath }}/admin/reader/explore" method="get" class="ap-explore-form"
14
31
  x-data="apInstanceSearch('{{ mountPath }}')"
@@ -90,6 +107,23 @@
90
107
 
91
108
  {# Results #}
92
109
  {% if instance and not error %}
110
+ {# Add to deck toggle button (shown when browsing results) #}
111
+ {% if items.length > 0 %}
112
+ <div class="ap-explore-deck-toggle"
113
+ x-data="apDeckToggle('{{ instance }}', '{{ scope }}', '{{ mountPath }}', '{{ csrfToken }}', {{ deckCount }}, {{ 'true' if isInDeck else 'false' }})">
114
+ <button
115
+ type="button"
116
+ class="ap-explore-deck-toggle__btn"
117
+ :class="{ 'ap-explore-deck-toggle__btn--active': inDeck }"
118
+ @click="toggle()"
119
+ :disabled="!inDeck && deckLimitReached"
120
+ :title="!inDeck && deckLimitReached ? '{{ __('activitypub.reader.explore.deck.deckLimitReached') }}' : (inDeck ? '{{ __('activitypub.reader.explore.deck.removeFromDeck') }}' : '{{ __('activitypub.reader.explore.deck.addToDeck') }}')"
121
+ :aria-label="!inDeck && deckLimitReached ? '{{ __('activitypub.reader.explore.deck.deckLimitReached') }}' : (inDeck ? '{{ __('activitypub.reader.explore.deck.removeFromDeck') }}' : '{{ __('activitypub.reader.explore.deck.addToDeck') }}')"
122
+ x-text="inDeck ? '★ {{ __('activitypub.reader.explore.deck.inDeck') }}' : '☆ {{ __('activitypub.reader.explore.deck.addToDeck') }}'">
123
+ </button>
124
+ </div>
125
+ {% endif %}
126
+
93
127
  {% if items.length > 0 %}
94
128
  <div class="ap-timeline ap-explore-timeline"
95
129
  id="ap-explore-timeline"
@@ -123,4 +157,62 @@
123
157
  {{ prose({ text: __("activitypub.reader.explore.noResults") }) }}
124
158
  {% endif %}
125
159
  {% endif %}
160
+
161
+ {% endif %}{# end Search tab #}
162
+
163
+ {# ── Decks tab ──────────────────────────────────────────────────── #}
164
+ {% if activeTab == 'decks' %}
165
+ {% if decks and decks.length > 0 %}
166
+ <div class="ap-deck-grid" data-csrf-token="{{ csrfToken }}">
167
+ {% for deck in decks %}
168
+ <div class="ap-deck-column"
169
+ x-data="apDeckColumn('{{ deck.domain }}', '{{ deck.scope }}', '{{ mountPath }}', {{ loop.index0 }}, '{{ csrfToken }}')"
170
+ x-init="init()">
171
+ <header class="ap-deck-column__header">
172
+ <span class="ap-deck-column__domain">{{ deck.domain }}</span>
173
+ <span class="ap-deck-column__scope-badge ap-deck-column__scope-badge--{{ deck.scope }}">
174
+ {{ __("activitypub.reader.explore.deck." + deck.scope + "Badge") }}
175
+ </span>
176
+ <button
177
+ type="button"
178
+ class="ap-deck-column__remove"
179
+ @click="removeDeck()"
180
+ title="{{ __('activitypub.reader.explore.deck.removeColumn') }}"
181
+ aria-label="{{ __('activitypub.reader.explore.deck.removeColumn') }}">×</button>
182
+ </header>
183
+ <div class="ap-deck-column__body" x-ref="body">
184
+ <div x-show="loading && itemCount === 0" class="ap-deck-column__loading">
185
+ <span>{{ __("activitypub.reader.pagination.loading") }}</span>
186
+ </div>
187
+ <div x-show="error" class="ap-deck-column__error" x-cloak>
188
+ <p x-text="error"></p>
189
+ <button type="button" class="ap-deck-column__retry" @click="retryLoad()">
190
+ {{ __("activitypub.reader.explore.deck.retry") }}
191
+ </button>
192
+ </div>
193
+ <div x-show="!loading && !error && itemCount === 0" class="ap-deck-column__empty" x-cloak>
194
+ {{ __("activitypub.reader.explore.noResults") }}
195
+ </div>
196
+ <div x-html="html" class="ap-deck-column__items"></div>
197
+ <div class="ap-deck-column__sentinel" x-ref="sentinel"></div>
198
+ <div x-show="loading && itemCount > 0" class="ap-deck-column__loading-more" x-cloak>
199
+ <span>{{ __("activitypub.reader.pagination.loading") }}</span>
200
+ </div>
201
+ <p x-show="done && itemCount > 0" class="ap-deck-column__done" x-cloak>
202
+ {{ __("activitypub.reader.pagination.noMore") }}
203
+ </p>
204
+ </div>
205
+ </div>
206
+ {% endfor %}
207
+ </div>
208
+ {% else %}
209
+ <div class="ap-deck-empty">
210
+ <p>{{ __("activitypub.reader.explore.deck.emptyState") }}</p>
211
+ <a href="{{ exploreBase }}" class="ap-deck-empty__link">
212
+ {{ __("activitypub.reader.explore.deck.emptyStateLink") }}
213
+ </a>
214
+ </div>
215
+ {% endif %}
216
+ {% endif %}{# end Decks tab #}
217
+
126
218
  {% endblock %}
@@ -5,6 +5,8 @@
5
5
  <script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-infinite-scroll.js"></script>
6
6
  {# Autocomplete components for explore + popular accounts #}
7
7
  <script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-autocomplete.js"></script>
8
+ {# Deck components — apDeckToggle and apDeckColumn #}
9
+ <script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-decks.js"></script>
8
10
 
9
11
  {# Alpine.js for client-side reactivity (CW toggles, interaction buttons, infinite scroll) #}
10
12
  <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js"></script>