@rmdes/indiekit-endpoint-activitypub 2.0.27 → 2.0.29

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,214 @@
1
+ /**
2
+ * Autocomplete — Alpine.js components for FediDB-powered search suggestions.
3
+ * Registers `apInstanceSearch` for the explore page instance input.
4
+ */
5
+
6
+ document.addEventListener("alpine:init", () => {
7
+ // eslint-disable-next-line no-undef
8
+ Alpine.data("apInstanceSearch", (mountPath) => ({
9
+ query: "",
10
+ suggestions: [],
11
+ showResults: false,
12
+ highlighted: -1,
13
+ abortController: null,
14
+
15
+ init() {
16
+ // Pick up server-rendered value (when returning to page with instance already loaded)
17
+ const input = this.$refs.input;
18
+ if (input && input.getAttribute("value")) {
19
+ this.query = input.getAttribute("value");
20
+ }
21
+ },
22
+
23
+ // Debounced search triggered by x-on:input
24
+ async search() {
25
+ const q = (this.query || "").trim();
26
+ if (q.length < 2) {
27
+ this.suggestions = [];
28
+ this.showResults = false;
29
+ return;
30
+ }
31
+
32
+ // Cancel any in-flight request
33
+ if (this.abortController) {
34
+ this.abortController.abort();
35
+ }
36
+ this.abortController = new AbortController();
37
+
38
+ try {
39
+ const res = await fetch(
40
+ `${mountPath}/admin/reader/api/instances?q=${encodeURIComponent(q)}`,
41
+ { signal: this.abortController.signal }
42
+ );
43
+ if (!res.ok) return;
44
+
45
+ const data = await res.json();
46
+ // Mark _timelineStatus as undefined (not yet checked)
47
+ this.suggestions = data.map((item) => ({
48
+ ...item,
49
+ _timelineStatus: undefined,
50
+ }));
51
+ this.highlighted = -1;
52
+ this.showResults = this.suggestions.length > 0;
53
+
54
+ // Fire timeline support checks in parallel (non-blocking)
55
+ this.checkTimelineSupport();
56
+ } catch (err) {
57
+ if (err.name !== "AbortError") {
58
+ this.suggestions = [];
59
+ this.showResults = false;
60
+ }
61
+ }
62
+ },
63
+
64
+ // Check timeline support for each suggestion (background, non-blocking)
65
+ async checkTimelineSupport() {
66
+ const items = [...this.suggestions];
67
+ for (const item of items) {
68
+ // Only check if still in the current suggestions list
69
+ const match = this.suggestions.find((s) => s.domain === item.domain);
70
+ if (!match) continue;
71
+
72
+ match._timelineStatus = "checking";
73
+
74
+ try {
75
+ const res = await fetch(
76
+ `${mountPath}/admin/reader/api/instance-check?domain=${encodeURIComponent(item.domain)}`
77
+ );
78
+ if (!res.ok) continue;
79
+
80
+ const data = await res.json();
81
+ // Update the item in the current suggestions (if still present)
82
+ const current = this.suggestions.find((s) => s.domain === item.domain);
83
+ if (current) {
84
+ current._timelineStatus = data.supported;
85
+ }
86
+ } catch {
87
+ const current = this.suggestions.find((s) => s.domain === item.domain);
88
+ if (current) {
89
+ current._timelineStatus = false;
90
+ }
91
+ }
92
+ }
93
+ },
94
+
95
+ selectItem(item) {
96
+ this.query = item.domain;
97
+ this.showResults = false;
98
+ this.suggestions = [];
99
+ this.$refs.input.focus();
100
+ },
101
+
102
+ close() {
103
+ this.showResults = false;
104
+ this.highlighted = -1;
105
+ },
106
+
107
+ highlightNext() {
108
+ if (!this.showResults || this.suggestions.length === 0) return;
109
+ this.highlighted = (this.highlighted + 1) % this.suggestions.length;
110
+ },
111
+
112
+ highlightPrev() {
113
+ if (!this.showResults || this.suggestions.length === 0) return;
114
+ this.highlighted =
115
+ this.highlighted <= 0
116
+ ? this.suggestions.length - 1
117
+ : this.highlighted - 1;
118
+ },
119
+
120
+ selectHighlighted(event) {
121
+ if (this.showResults && this.highlighted >= 0 && this.suggestions[this.highlighted]) {
122
+ event.preventDefault();
123
+ this.selectItem(this.suggestions[this.highlighted]);
124
+ }
125
+ // Otherwise let the form submit naturally
126
+ },
127
+
128
+ onSubmit() {
129
+ this.close();
130
+ },
131
+ }));
132
+
133
+ // eslint-disable-next-line no-undef
134
+ Alpine.data("apPopularAccounts", (mountPath) => ({
135
+ query: "",
136
+ suggestions: [],
137
+ allAccounts: [],
138
+ showResults: false,
139
+ highlighted: -1,
140
+ loaded: false,
141
+
142
+ // Load popular accounts on first focus (lazy)
143
+ async loadAccounts() {
144
+ if (this.loaded) return;
145
+ this.loaded = true;
146
+
147
+ try {
148
+ const res = await fetch(`${mountPath}/admin/reader/api/popular-accounts`);
149
+ if (!res.ok) return;
150
+ this.allAccounts = await res.json();
151
+ } catch {
152
+ // Non-critical
153
+ }
154
+ },
155
+
156
+ // Filter locally from preloaded list
157
+ filterAccounts() {
158
+ const q = (this.query || "").trim().toLowerCase();
159
+ if (q.length < 1 || this.allAccounts.length === 0) {
160
+ this.suggestions = [];
161
+ this.showResults = false;
162
+ return;
163
+ }
164
+
165
+ this.suggestions = this.allAccounts
166
+ .filter(
167
+ (a) =>
168
+ a.username.toLowerCase().includes(q) ||
169
+ a.name.toLowerCase().includes(q) ||
170
+ a.domain.toLowerCase().includes(q) ||
171
+ a.handle.toLowerCase().includes(q)
172
+ )
173
+ .slice(0, 8);
174
+ this.highlighted = -1;
175
+ this.showResults = this.suggestions.length > 0;
176
+ },
177
+
178
+ selectItem(item) {
179
+ this.query = item.handle;
180
+ this.showResults = false;
181
+ this.suggestions = [];
182
+ this.$refs.input.focus();
183
+ },
184
+
185
+ close() {
186
+ this.showResults = false;
187
+ this.highlighted = -1;
188
+ },
189
+
190
+ highlightNext() {
191
+ if (!this.showResults || this.suggestions.length === 0) return;
192
+ this.highlighted = (this.highlighted + 1) % this.suggestions.length;
193
+ },
194
+
195
+ highlightPrev() {
196
+ if (!this.showResults || this.suggestions.length === 0) return;
197
+ this.highlighted =
198
+ this.highlighted <= 0
199
+ ? this.suggestions.length - 1
200
+ : this.highlighted - 1;
201
+ },
202
+
203
+ selectHighlighted(event) {
204
+ if (this.showResults && this.highlighted >= 0 && this.suggestions[this.highlighted]) {
205
+ event.preventDefault();
206
+ this.selectItem(this.suggestions[this.highlighted]);
207
+ }
208
+ },
209
+
210
+ onSubmit() {
211
+ this.close();
212
+ },
213
+ }));
214
+ });
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Infinite scroll — AlpineJS component for AJAX load-more on the timeline
3
+ * Registers the `apInfiniteScroll` Alpine data component.
4
+ */
5
+
6
+ document.addEventListener("alpine:init", () => {
7
+ // eslint-disable-next-line no-undef
8
+ Alpine.data("apExploreScroll", () => ({
9
+ loading: false,
10
+ done: false,
11
+ maxId: null,
12
+ instance: "",
13
+ scope: "local",
14
+ observer: null,
15
+
16
+ init() {
17
+ const el = this.$el;
18
+ this.maxId = el.dataset.maxId || null;
19
+ this.instance = el.dataset.instance || "";
20
+ this.scope = el.dataset.scope || "local";
21
+
22
+ if (!this.maxId) {
23
+ this.done = true;
24
+ return;
25
+ }
26
+
27
+ this.observer = new IntersectionObserver(
28
+ (entries) => {
29
+ for (const entry of entries) {
30
+ if (entry.isIntersecting && !this.loading && !this.done) {
31
+ this.loadMore();
32
+ }
33
+ }
34
+ },
35
+ { rootMargin: "200px" }
36
+ );
37
+
38
+ if (this.$refs.sentinel) {
39
+ this.observer.observe(this.$refs.sentinel);
40
+ }
41
+ },
42
+
43
+ async loadMore() {
44
+ if (this.loading || this.done || !this.maxId) return;
45
+
46
+ this.loading = true;
47
+
48
+ const timeline = document.getElementById("ap-explore-timeline");
49
+ const mountPath = timeline ? timeline.dataset.mountPath : "";
50
+
51
+ const params = new URLSearchParams({
52
+ instance: this.instance,
53
+ scope: this.scope,
54
+ max_id: this.maxId,
55
+ });
56
+
57
+ try {
58
+ const res = await fetch(
59
+ `${mountPath}/admin/reader/api/explore?${params.toString()}`,
60
+ { headers: { Accept: "application/json" } }
61
+ );
62
+
63
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
64
+
65
+ const data = await res.json();
66
+
67
+ if (data.html && timeline) {
68
+ timeline.insertAdjacentHTML("beforeend", data.html);
69
+ }
70
+
71
+ if (data.maxId) {
72
+ this.maxId = data.maxId;
73
+ } else {
74
+ this.done = true;
75
+ if (this.observer) this.observer.disconnect();
76
+ }
77
+ } catch (err) {
78
+ console.error("[ap-explore-scroll] load failed:", err.message);
79
+ } finally {
80
+ this.loading = false;
81
+ }
82
+ },
83
+
84
+ destroy() {
85
+ if (this.observer) this.observer.disconnect();
86
+ },
87
+ }));
88
+
89
+ // eslint-disable-next-line no-undef
90
+ Alpine.data("apInfiniteScroll", () => ({
91
+ loading: false,
92
+ done: false,
93
+ before: null,
94
+ tab: "",
95
+ tag: "",
96
+ observer: null,
97
+
98
+ init() {
99
+ const el = this.$el;
100
+ this.before = el.dataset.before || null;
101
+ this.tab = el.dataset.tab || "";
102
+ this.tag = el.dataset.tag || "";
103
+
104
+ // Hide the no-JS pagination fallback now that JS is active
105
+ const paginationEl =
106
+ document.getElementById("ap-reader-pagination") ||
107
+ document.getElementById("ap-tag-pagination");
108
+ if (paginationEl) {
109
+ paginationEl.style.display = "none";
110
+ }
111
+
112
+ if (!this.before) {
113
+ this.done = true;
114
+ return;
115
+ }
116
+
117
+ // Set up IntersectionObserver to auto-load when sentinel comes into view
118
+ this.observer = new IntersectionObserver(
119
+ (entries) => {
120
+ for (const entry of entries) {
121
+ if (entry.isIntersecting && !this.loading && !this.done) {
122
+ this.loadMore();
123
+ }
124
+ }
125
+ },
126
+ { rootMargin: "200px" }
127
+ );
128
+
129
+ if (this.$refs.sentinel) {
130
+ this.observer.observe(this.$refs.sentinel);
131
+ }
132
+ },
133
+
134
+ async loadMore() {
135
+ if (this.loading || this.done || !this.before) return;
136
+
137
+ this.loading = true;
138
+
139
+ const timeline = document.getElementById("ap-timeline");
140
+ const mountPath = timeline ? timeline.dataset.mountPath : "";
141
+
142
+ const params = new URLSearchParams({ before: this.before });
143
+ if (this.tab) params.set("tab", this.tab);
144
+ if (this.tag) params.set("tag", this.tag);
145
+
146
+ try {
147
+ const res = await fetch(
148
+ `${mountPath}/admin/reader/api/timeline?${params.toString()}`,
149
+ { headers: { Accept: "application/json" } }
150
+ );
151
+
152
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
153
+
154
+ const data = await res.json();
155
+
156
+ if (data.html && timeline) {
157
+ // Append the returned pre-rendered HTML
158
+ timeline.insertAdjacentHTML("beforeend", data.html);
159
+ }
160
+
161
+ if (data.before) {
162
+ this.before = data.before;
163
+ } else {
164
+ // No more items
165
+ this.done = true;
166
+ if (this.observer) this.observer.disconnect();
167
+ }
168
+ } catch (err) {
169
+ console.error("[ap-infinite-scroll] load failed:", err.message);
170
+ } finally {
171
+ this.loading = false;
172
+ }
173
+ },
174
+
175
+ appendItems(/* detail */) {
176
+ // Custom event hook — not used in this implementation but kept for extensibility
177
+ },
178
+
179
+ destroy() {
180
+ if (this.observer) this.observer.disconnect();
181
+ },
182
+ }));
183
+ });