@rmdes/indiekit-endpoint-activitypub 2.0.29 → 2.0.31

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
@@ -15,14 +15,15 @@
15
15
  }
16
16
 
17
17
  .ap-lookup__input {
18
- flex: 1;
19
- padding: var(--space-s) var(--space-m);
20
18
  border: var(--border-width-thin) solid var(--color-outline);
21
19
  border-radius: var(--border-radius-small);
22
20
  background: var(--color-offset);
21
+ box-sizing: border-box;
23
22
  color: var(--color-on-background);
24
- font-size: var(--font-size-m);
25
23
  font-family: inherit;
24
+ font-size: var(--font-size-m);
25
+ padding: var(--space-s) var(--space-m);
26
+ width: 100%;
26
27
  }
27
28
 
28
29
  .ap-lookup__input::placeholder {
@@ -1784,10 +1785,11 @@
1784
1785
  .ap-explore-form__input {
1785
1786
  border: var(--border-width-thin) solid var(--color-outline);
1786
1787
  border-radius: var(--border-radius-small);
1787
- flex: 1;
1788
+ box-sizing: border-box;
1788
1789
  font-size: var(--font-size-base);
1789
1790
  min-width: 0;
1790
1791
  padding: var(--space-xs) var(--space-s);
1792
+ width: 100%;
1791
1793
  }
1792
1794
 
1793
1795
  .ap-explore-form__scope {
@@ -2020,3 +2022,190 @@
2020
2022
  color: var(--color-on-offset);
2021
2023
  font-weight: 600;
2022
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.
@@ -1047,6 +1065,29 @@ export default class ActivityPubEndpoint {
1047
1065
  this._federation = federation;
1048
1066
  this._fedifyMiddleware = createFedifyMiddleware(federation, () => ({}));
1049
1067
 
1068
+ // Expose signed avatar resolver for cross-plugin use (e.g., conversations backfill)
1069
+ Indiekit.config.application.resolveActorAvatar = async (actorUrl) => {
1070
+ try {
1071
+ const handle = this.options.actor.handle;
1072
+ const ctx = this._federation.createContext(
1073
+ new URL(this._publicationUrl),
1074
+ { handle, publicationUrl: this._publicationUrl },
1075
+ );
1076
+ const documentLoader = await ctx.getDocumentLoader({
1077
+ identifier: handle,
1078
+ });
1079
+ const actor = await ctx.lookupObject(new URL(actorUrl), {
1080
+ documentLoader,
1081
+ });
1082
+ if (!actor) return "";
1083
+ const { extractActorInfo } = await import("./lib/timeline-store.js");
1084
+ const info = await extractActorInfo(actor, { documentLoader });
1085
+ return info.photo || "";
1086
+ } catch {
1087
+ return "";
1088
+ }
1089
+ };
1090
+
1050
1091
  // Register as endpoint (mounts routesPublic, routesWellKnown, routes)
1051
1092
  Indiekit.addEndpoint(this);
1052
1093
 
@@ -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/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.29",
3
+ "version": "2.0.31",
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>