@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.
- package/assets/reader-decks.js +212 -0
- package/assets/reader.css +187 -0
- package/index.js +18 -0
- package/lib/controllers/decks.js +137 -0
- package/lib/controllers/explore.js +38 -2
- package/lib/fedidb.js +75 -28
- package/locales/en.json +18 -1
- package/package.json +1 -1
- package/views/activitypub-explore.njk +92 -0
- package/views/layouts/ap-reader.njk +2 -0
|
@@ -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
|
|
5
|
+
* - /servers — cursor-paginated list of known fediverse instances (ranked by size)
|
|
6
6
|
* - /popular-accounts — top accounts by follower count
|
|
7
7
|
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
75
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
85
|
-
const q = (query || "").trim().toLowerCase();
|
|
86
|
-
if (!q) return [];
|
|
87
|
+
const MAX_PAGES = 13;
|
|
87
88
|
|
|
88
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
110
|
-
|
|
125
|
+
if (results.length > 0) {
|
|
126
|
+
await writeToCache(kvCollection, cacheKey, results);
|
|
127
|
+
}
|
|
111
128
|
} catch {
|
|
112
|
-
|
|
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.
|
|
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>
|