@rmdes/indiekit-endpoint-activitypub 2.0.36 → 2.1.1
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-infinite-scroll.js +6 -0
- package/assets/reader-tabs.js +643 -0
- package/assets/reader.css +222 -117
- package/index.js +26 -14
- package/lib/controllers/explore-utils.js +122 -0
- package/lib/controllers/explore.js +30 -143
- package/lib/controllers/hashtag-explore.js +225 -0
- package/lib/controllers/tabs.js +245 -0
- package/locales/en.json +16 -13
- package/package.json +1 -1
- package/views/activitypub-explore.njk +364 -193
- package/views/layouts/ap-reader.njk +2 -2
- package/assets/reader-decks.js +0 -212
- package/lib/controllers/decks.js +0 -137
|
@@ -11,6 +11,7 @@ document.addEventListener("alpine:init", () => {
|
|
|
11
11
|
maxId: null,
|
|
12
12
|
instance: "",
|
|
13
13
|
scope: "local",
|
|
14
|
+
hashtag: "",
|
|
14
15
|
observer: null,
|
|
15
16
|
|
|
16
17
|
init() {
|
|
@@ -18,6 +19,7 @@ document.addEventListener("alpine:init", () => {
|
|
|
18
19
|
this.maxId = el.dataset.maxId || null;
|
|
19
20
|
this.instance = el.dataset.instance || "";
|
|
20
21
|
this.scope = el.dataset.scope || "local";
|
|
22
|
+
this.hashtag = el.dataset.hashtag || "";
|
|
21
23
|
|
|
22
24
|
if (!this.maxId) {
|
|
23
25
|
this.done = true;
|
|
@@ -53,6 +55,10 @@ document.addEventListener("alpine:init", () => {
|
|
|
53
55
|
scope: this.scope,
|
|
54
56
|
max_id: this.maxId,
|
|
55
57
|
});
|
|
58
|
+
// Pass hashtag when in hashtag mode so infinite scroll stays on tag timeline
|
|
59
|
+
if (this.hashtag) {
|
|
60
|
+
params.set("hashtag", this.hashtag);
|
|
61
|
+
}
|
|
56
62
|
|
|
57
63
|
try {
|
|
58
64
|
const res = await fetch(
|
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tab components — Alpine.js component for the tabbed explore page.
|
|
3
|
+
*
|
|
4
|
+
* Registers:
|
|
5
|
+
* apExploreTabs — tab management, timeline loading, infinite scroll
|
|
6
|
+
*
|
|
7
|
+
* Guard: init() exits early when .ap-explore-tabs-container is absent so
|
|
8
|
+
* this script is safe to load on all reader pages via the shared layout.
|
|
9
|
+
*
|
|
10
|
+
* Configuration is read from data-* attributes on the root element:
|
|
11
|
+
* data-mount-path — plugin mount path for API URL construction
|
|
12
|
+
* data-csrf — CSRF token from server session
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
document.addEventListener("alpine:init", () => {
|
|
16
|
+
// eslint-disable-next-line no-undef
|
|
17
|
+
Alpine.data("apExploreTabs", () => ({
|
|
18
|
+
// ── Tab list and active state ────────────────────────────────────────────
|
|
19
|
+
tabs: [],
|
|
20
|
+
activeTabId: null, // null = Search tab; string = user tab _id
|
|
21
|
+
|
|
22
|
+
// ── Tab management UI state ──────────────────────────────────────────────
|
|
23
|
+
pinning: false,
|
|
24
|
+
showHashtagForm: false,
|
|
25
|
+
hashtagInput: "",
|
|
26
|
+
error: null,
|
|
27
|
+
|
|
28
|
+
// ── Per-tab content state (keyed by tab _id) ─────────────────────────────
|
|
29
|
+
// Each entry: { loading, error, html, maxId, done, abortController }
|
|
30
|
+
// Hashtag tabs additionally carry: { cursors, sourceMeta }
|
|
31
|
+
// cursors: { [domain]: maxId|null } — per-instance pagination cursors
|
|
32
|
+
// sourceMeta: { instancesQueried, instancesTotal, instanceLabels }
|
|
33
|
+
tabState: {},
|
|
34
|
+
|
|
35
|
+
// ── Bounded content cache (last 5 tabs, LRU by access order) ────────────
|
|
36
|
+
_cacheOrder: [],
|
|
37
|
+
|
|
38
|
+
// ── Scroll observer for the active tab ───────────────────────────────────
|
|
39
|
+
_tabObserver: null,
|
|
40
|
+
|
|
41
|
+
// ── Configuration (read from data attributes) ────────────────────────────
|
|
42
|
+
_mountPath: "",
|
|
43
|
+
_csrfToken: "",
|
|
44
|
+
_reorderTimer: null,
|
|
45
|
+
|
|
46
|
+
// ── Lifecycle ────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
init() {
|
|
49
|
+
if (!document.querySelector(".ap-explore-tabs-container")) return;
|
|
50
|
+
this._mountPath = this.$el.dataset.mountPath || "";
|
|
51
|
+
this._csrfToken = this.$el.dataset.csrf || "";
|
|
52
|
+
this._loadTabs();
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
destroy() {
|
|
56
|
+
if (this._tabObserver) {
|
|
57
|
+
this._tabObserver.disconnect();
|
|
58
|
+
this._tabObserver = null;
|
|
59
|
+
}
|
|
60
|
+
if (this._reorderTimer) {
|
|
61
|
+
clearTimeout(this._reorderTimer);
|
|
62
|
+
this._reorderTimer = null;
|
|
63
|
+
}
|
|
64
|
+
// Abort any in-flight requests
|
|
65
|
+
for (const state of Object.values(this.tabState)) {
|
|
66
|
+
if (state.abortController) state.abortController.abort();
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
async _loadTabs() {
|
|
71
|
+
try {
|
|
72
|
+
const res = await fetch(
|
|
73
|
+
`${this._mountPath}/admin/reader/api/tabs`,
|
|
74
|
+
{ headers: { Accept: "application/json" } }
|
|
75
|
+
);
|
|
76
|
+
if (!res.ok) return;
|
|
77
|
+
const data = await res.json();
|
|
78
|
+
this.tabs = data.map((t) => ({ ...t, _id: String(t._id) }));
|
|
79
|
+
} catch {
|
|
80
|
+
// Non-critical — tab bar degrades gracefully to Search-only
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
// ── Tab content state helpers ─────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
_getState(tabId) {
|
|
87
|
+
return this.tabState[tabId] || {
|
|
88
|
+
loading: false, error: null, html: "", maxId: null, done: false,
|
|
89
|
+
abortController: null,
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
_setState(tabId, update) {
|
|
94
|
+
const current = this._getState(tabId);
|
|
95
|
+
this.tabState = { ...this.tabState, [tabId]: { ...current, ...update } };
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
// LRU cache management — evict oldest when cache grows past 5 tabs
|
|
99
|
+
_touchCache(tabId) {
|
|
100
|
+
this._cacheOrder = this._cacheOrder.filter((id) => id !== tabId);
|
|
101
|
+
this._cacheOrder.push(tabId);
|
|
102
|
+
|
|
103
|
+
while (this._cacheOrder.length > 5) {
|
|
104
|
+
const evictId = this._cacheOrder.shift();
|
|
105
|
+
const evictedState = this.tabState[evictId];
|
|
106
|
+
if (evictedState) {
|
|
107
|
+
this._setState(evictId, {
|
|
108
|
+
html: "", maxId: null, done: false, loading: false,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
// ── Tab switching ─────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
switchToSearch() {
|
|
117
|
+
this._abortActiveTabFetch();
|
|
118
|
+
this._teardownScrollObserver();
|
|
119
|
+
this.activeTabId = null;
|
|
120
|
+
this.error = null;
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
switchTab(tabId) {
|
|
124
|
+
if (this.activeTabId === tabId) return;
|
|
125
|
+
this._abortActiveTabFetch();
|
|
126
|
+
this._teardownScrollObserver();
|
|
127
|
+
this.activeTabId = tabId;
|
|
128
|
+
this.error = null;
|
|
129
|
+
|
|
130
|
+
const tab = this.tabs.find((t) => t._id === tabId);
|
|
131
|
+
if (!tab) return;
|
|
132
|
+
|
|
133
|
+
const state = this._getState(tabId);
|
|
134
|
+
|
|
135
|
+
if (tab.type === "instance") {
|
|
136
|
+
if (!state.html && !state.loading) {
|
|
137
|
+
// Cache miss — load first page
|
|
138
|
+
this.$nextTick(() => this._loadInstanceTab(tab));
|
|
139
|
+
} else if (state.html) {
|
|
140
|
+
// Cache hit — restore scroll observer
|
|
141
|
+
this._touchCache(tabId);
|
|
142
|
+
this.$nextTick(() => this._setupScrollObserver(tab));
|
|
143
|
+
}
|
|
144
|
+
} else if (tab.type === "hashtag") {
|
|
145
|
+
if (!state.html && !state.loading) {
|
|
146
|
+
this.$nextTick(() => this._loadHashtagTab(tab));
|
|
147
|
+
} else if (state.html) {
|
|
148
|
+
this._touchCache(tabId);
|
|
149
|
+
this.$nextTick(() => this._setupScrollObserver(tab));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
_abortActiveTabFetch() {
|
|
155
|
+
if (!this.activeTabId) return;
|
|
156
|
+
const state = this._getState(this.activeTabId);
|
|
157
|
+
if (state.abortController) {
|
|
158
|
+
state.abortController.abort();
|
|
159
|
+
this._setState(this.activeTabId, {
|
|
160
|
+
abortController: null,
|
|
161
|
+
loading: false,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
// ── Instance tab loading ──────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
async _loadInstanceTab(tab) {
|
|
169
|
+
const tabId = tab._id;
|
|
170
|
+
const abortController = new AbortController();
|
|
171
|
+
this._setState(tabId, {
|
|
172
|
+
loading: true, error: null, abortController,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
const url = new URL(
|
|
177
|
+
`${this._mountPath}/admin/reader/api/explore`,
|
|
178
|
+
window.location.origin
|
|
179
|
+
);
|
|
180
|
+
url.searchParams.set("instance", tab.domain);
|
|
181
|
+
url.searchParams.set("scope", tab.scope);
|
|
182
|
+
|
|
183
|
+
const res = await fetch(url.toString(), {
|
|
184
|
+
headers: { Accept: "application/json" },
|
|
185
|
+
signal: abortController.signal,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
189
|
+
|
|
190
|
+
const data = await res.json();
|
|
191
|
+
|
|
192
|
+
this._setState(tabId, {
|
|
193
|
+
loading: false,
|
|
194
|
+
abortController: null,
|
|
195
|
+
html: data.html || "",
|
|
196
|
+
maxId: data.maxId || null,
|
|
197
|
+
done: !data.maxId,
|
|
198
|
+
error: null,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
this._touchCache(tabId);
|
|
202
|
+
|
|
203
|
+
// Set up scroll observer after DOM updates
|
|
204
|
+
this.$nextTick(() => this._setupScrollObserver(tab));
|
|
205
|
+
} catch (err) {
|
|
206
|
+
if (err.name === "AbortError") return; // Tab was switched away — silent
|
|
207
|
+
this._setState(tabId, {
|
|
208
|
+
loading: false,
|
|
209
|
+
abortController: null,
|
|
210
|
+
error: err.message || "Could not load timeline",
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
async _loadMoreInstanceTab(tab) {
|
|
216
|
+
const tabId = tab._id;
|
|
217
|
+
const state = this._getState(tabId);
|
|
218
|
+
if (state.loading || state.done || !state.maxId) return;
|
|
219
|
+
|
|
220
|
+
const abortController = new AbortController();
|
|
221
|
+
this._setState(tabId, { loading: true, abortController });
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const url = new URL(
|
|
225
|
+
`${this._mountPath}/admin/reader/api/explore`,
|
|
226
|
+
window.location.origin
|
|
227
|
+
);
|
|
228
|
+
url.searchParams.set("instance", tab.domain);
|
|
229
|
+
url.searchParams.set("scope", tab.scope);
|
|
230
|
+
url.searchParams.set("max_id", state.maxId);
|
|
231
|
+
|
|
232
|
+
const res = await fetch(url.toString(), {
|
|
233
|
+
headers: { Accept: "application/json" },
|
|
234
|
+
signal: abortController.signal,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
238
|
+
|
|
239
|
+
const data = await res.json();
|
|
240
|
+
const current = this._getState(tabId);
|
|
241
|
+
|
|
242
|
+
this._setState(tabId, {
|
|
243
|
+
loading: false,
|
|
244
|
+
abortController: null,
|
|
245
|
+
html: current.html + (data.html || ""),
|
|
246
|
+
maxId: data.maxId || null,
|
|
247
|
+
done: !data.maxId,
|
|
248
|
+
});
|
|
249
|
+
} catch (err) {
|
|
250
|
+
if (err.name === "AbortError") return;
|
|
251
|
+
this._setState(tabId, {
|
|
252
|
+
loading: false,
|
|
253
|
+
abortController: null,
|
|
254
|
+
error: err.message || "Could not load more posts",
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
// ── Hashtag tab loading ───────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
async _loadHashtagTab(tab) {
|
|
262
|
+
const tabId = tab._id;
|
|
263
|
+
const abortController = new AbortController();
|
|
264
|
+
this._setState(tabId, { loading: true, error: null, abortController });
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
const url = new URL(
|
|
268
|
+
`${this._mountPath}/admin/reader/api/explore/hashtag`,
|
|
269
|
+
window.location.origin
|
|
270
|
+
);
|
|
271
|
+
url.searchParams.set("hashtag", tab.hashtag);
|
|
272
|
+
url.searchParams.set("cursors", "{}");
|
|
273
|
+
|
|
274
|
+
const res = await fetch(url.toString(), {
|
|
275
|
+
headers: { Accept: "application/json" },
|
|
276
|
+
signal: abortController.signal,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
280
|
+
|
|
281
|
+
const data = await res.json();
|
|
282
|
+
|
|
283
|
+
this._setState(tabId, {
|
|
284
|
+
loading: false,
|
|
285
|
+
abortController: null,
|
|
286
|
+
html: data.html || "",
|
|
287
|
+
cursors: data.cursors || {},
|
|
288
|
+
sourceMeta: {
|
|
289
|
+
instancesQueried: data.instancesQueried || 0,
|
|
290
|
+
instancesTotal: data.instancesTotal || 0,
|
|
291
|
+
instanceLabels: data.instanceLabels || [],
|
|
292
|
+
},
|
|
293
|
+
done: !data.html || Object.values(data.cursors || {}).every((c) => !c),
|
|
294
|
+
error: null,
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
this._touchCache(tabId);
|
|
298
|
+
this.$nextTick(() => this._setupScrollObserver(tab));
|
|
299
|
+
} catch (err) {
|
|
300
|
+
if (err.name === "AbortError") return;
|
|
301
|
+
this._setState(tabId, {
|
|
302
|
+
loading: false,
|
|
303
|
+
abortController: null,
|
|
304
|
+
error: err.message || "Could not load hashtag timeline",
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
|
|
309
|
+
async _loadMoreHashtagTab(tab) {
|
|
310
|
+
const tabId = tab._id;
|
|
311
|
+
const state = this._getState(tabId);
|
|
312
|
+
if (state.loading || state.done) return;
|
|
313
|
+
|
|
314
|
+
const abortController = new AbortController();
|
|
315
|
+
this._setState(tabId, { loading: true, abortController });
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
const url = new URL(
|
|
319
|
+
`${this._mountPath}/admin/reader/api/explore/hashtag`,
|
|
320
|
+
window.location.origin
|
|
321
|
+
);
|
|
322
|
+
url.searchParams.set("hashtag", tab.hashtag);
|
|
323
|
+
url.searchParams.set("cursors", JSON.stringify(state.cursors || {}));
|
|
324
|
+
|
|
325
|
+
const res = await fetch(url.toString(), {
|
|
326
|
+
headers: { Accept: "application/json" },
|
|
327
|
+
signal: abortController.signal,
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
331
|
+
|
|
332
|
+
const data = await res.json();
|
|
333
|
+
const current = this._getState(tabId);
|
|
334
|
+
|
|
335
|
+
const allCursorsExhausted = Object.values(data.cursors || {}).every(
|
|
336
|
+
(c) => !c
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
this._setState(tabId, {
|
|
340
|
+
loading: false,
|
|
341
|
+
abortController: null,
|
|
342
|
+
html: current.html + (data.html || ""),
|
|
343
|
+
cursors: data.cursors || {},
|
|
344
|
+
done: !data.html || allCursorsExhausted,
|
|
345
|
+
});
|
|
346
|
+
} catch (err) {
|
|
347
|
+
if (err.name === "AbortError") return;
|
|
348
|
+
this._setState(tabId, {
|
|
349
|
+
loading: false,
|
|
350
|
+
abortController: null,
|
|
351
|
+
error: err.message || "Could not load more posts",
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
|
|
356
|
+
async retryTab(tab) {
|
|
357
|
+
const tabId = tab._id;
|
|
358
|
+
this._setState(tabId, {
|
|
359
|
+
error: null, html: "", maxId: null, done: false,
|
|
360
|
+
cursors: {}, sourceMeta: null,
|
|
361
|
+
});
|
|
362
|
+
if (tab.type === "instance") {
|
|
363
|
+
await this._loadInstanceTab(tab);
|
|
364
|
+
} else if (tab.type === "hashtag") {
|
|
365
|
+
await this._loadHashtagTab(tab);
|
|
366
|
+
}
|
|
367
|
+
},
|
|
368
|
+
|
|
369
|
+
// ── Infinite scroll for tab panels ───────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
_setupScrollObserver(tab) {
|
|
372
|
+
this._teardownScrollObserver();
|
|
373
|
+
|
|
374
|
+
const panel = this.$el.querySelector(`#ap-tab-panel-${tab._id}`);
|
|
375
|
+
if (!panel) return;
|
|
376
|
+
|
|
377
|
+
const sentinel = panel.querySelector(".ap-tab-sentinel");
|
|
378
|
+
if (!sentinel) return;
|
|
379
|
+
|
|
380
|
+
this._tabObserver = new IntersectionObserver(
|
|
381
|
+
(entries) => {
|
|
382
|
+
for (const entry of entries) {
|
|
383
|
+
if (entry.isIntersecting) {
|
|
384
|
+
const state = this._getState(tab._id);
|
|
385
|
+
if (!state.loading && !state.done) {
|
|
386
|
+
if (tab.type === "instance" && state.maxId) {
|
|
387
|
+
this._loadMoreInstanceTab(tab);
|
|
388
|
+
} else if (tab.type === "hashtag") {
|
|
389
|
+
this._loadMoreHashtagTab(tab);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
},
|
|
395
|
+
{ rootMargin: "200px" }
|
|
396
|
+
);
|
|
397
|
+
this._tabObserver.observe(sentinel);
|
|
398
|
+
},
|
|
399
|
+
|
|
400
|
+
_teardownScrollObserver() {
|
|
401
|
+
if (this._tabObserver) {
|
|
402
|
+
this._tabObserver.disconnect();
|
|
403
|
+
this._tabObserver = null;
|
|
404
|
+
}
|
|
405
|
+
},
|
|
406
|
+
|
|
407
|
+
// ── Tab label helpers ─────────────────────────────────────────────────────
|
|
408
|
+
|
|
409
|
+
tabLabel(tab) {
|
|
410
|
+
return tab.type === "instance" ? tab.domain : `#${tab.hashtag}`;
|
|
411
|
+
},
|
|
412
|
+
|
|
413
|
+
hashtagSourcesLine(tab) {
|
|
414
|
+
const state = this._getState(tab._id);
|
|
415
|
+
const meta = state.sourceMeta;
|
|
416
|
+
if (!meta || !meta.instancesQueried) return "";
|
|
417
|
+
const n = meta.instancesQueried;
|
|
418
|
+
const total = meta.instancesTotal;
|
|
419
|
+
const labels = meta.instanceLabels || [];
|
|
420
|
+
const tag = tab.hashtag || "";
|
|
421
|
+
const suffix = n === 1 ? "instance" : "instances";
|
|
422
|
+
let line = `Searching #${tag} across ${n} ${suffix}`;
|
|
423
|
+
if (n < total) {
|
|
424
|
+
line += ` (${n} of ${total} pinned)`;
|
|
425
|
+
}
|
|
426
|
+
if (labels.length > 0) {
|
|
427
|
+
line += `: ${labels.join(", ")}`;
|
|
428
|
+
}
|
|
429
|
+
return line;
|
|
430
|
+
},
|
|
431
|
+
|
|
432
|
+
// ── Keyboard navigation (WAI-ARIA Tabs pattern) ───────────────────────────
|
|
433
|
+
|
|
434
|
+
handleTabKeydown(event, currentIndex) {
|
|
435
|
+
const total = this.tabs.length + 1;
|
|
436
|
+
let nextIndex = null;
|
|
437
|
+
|
|
438
|
+
if (event.key === "ArrowRight") {
|
|
439
|
+
event.preventDefault();
|
|
440
|
+
nextIndex = (currentIndex + 1) % total;
|
|
441
|
+
} else if (event.key === "ArrowLeft") {
|
|
442
|
+
event.preventDefault();
|
|
443
|
+
nextIndex = (currentIndex - 1 + total) % total;
|
|
444
|
+
} else if (event.key === "Home") {
|
|
445
|
+
event.preventDefault();
|
|
446
|
+
nextIndex = 0;
|
|
447
|
+
} else if (event.key === "End") {
|
|
448
|
+
event.preventDefault();
|
|
449
|
+
nextIndex = total - 1;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (nextIndex !== null) {
|
|
453
|
+
const tabEls = this.$el.querySelectorAll('[role="tab"]');
|
|
454
|
+
if (tabEls[nextIndex]) tabEls[nextIndex].focus();
|
|
455
|
+
}
|
|
456
|
+
},
|
|
457
|
+
|
|
458
|
+
// ── Pin current search result as instance tab ─────────────────────────────
|
|
459
|
+
|
|
460
|
+
async pinInstance(domain, scope) {
|
|
461
|
+
if (this.pinning) return;
|
|
462
|
+
this.pinning = true;
|
|
463
|
+
this.error = null;
|
|
464
|
+
|
|
465
|
+
try {
|
|
466
|
+
const res = await fetch(
|
|
467
|
+
`${this._mountPath}/admin/reader/api/tabs`,
|
|
468
|
+
{
|
|
469
|
+
method: "POST",
|
|
470
|
+
headers: {
|
|
471
|
+
"Content-Type": "application/json",
|
|
472
|
+
"X-CSRF-Token": this._csrfToken,
|
|
473
|
+
},
|
|
474
|
+
body: JSON.stringify({ type: "instance", domain, scope }),
|
|
475
|
+
}
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
if (res.status === 409) {
|
|
479
|
+
const existing = this.tabs.find(
|
|
480
|
+
(t) => t.type === "instance" && t.domain === domain && t.scope === scope
|
|
481
|
+
);
|
|
482
|
+
if (existing) this.switchTab(existing._id);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (res.status === 403) {
|
|
487
|
+
this.error = "Session expired — please refresh the page.";
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (!res.ok) return;
|
|
492
|
+
|
|
493
|
+
const newTab = await res.json();
|
|
494
|
+
newTab._id = String(newTab._id);
|
|
495
|
+
this.tabs.push(newTab);
|
|
496
|
+
this.switchTab(newTab._id);
|
|
497
|
+
} catch {
|
|
498
|
+
// Network error — silent
|
|
499
|
+
} finally {
|
|
500
|
+
this.pinning = false;
|
|
501
|
+
}
|
|
502
|
+
},
|
|
503
|
+
|
|
504
|
+
// ── Add hashtag tab ───────────────────────────────────────────────────────
|
|
505
|
+
|
|
506
|
+
async submitHashtagTab() {
|
|
507
|
+
const hashtag = (this.hashtagInput || "").replace(/^#+/, "").trim();
|
|
508
|
+
if (!hashtag) return;
|
|
509
|
+
|
|
510
|
+
try {
|
|
511
|
+
const res = await fetch(
|
|
512
|
+
`${this._mountPath}/admin/reader/api/tabs`,
|
|
513
|
+
{
|
|
514
|
+
method: "POST",
|
|
515
|
+
headers: {
|
|
516
|
+
"Content-Type": "application/json",
|
|
517
|
+
"X-CSRF-Token": this._csrfToken,
|
|
518
|
+
},
|
|
519
|
+
body: JSON.stringify({ type: "hashtag", hashtag }),
|
|
520
|
+
}
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
if (res.status === 409) {
|
|
524
|
+
const existing = this.tabs.find(
|
|
525
|
+
(t) => t.type === "hashtag" && t.hashtag === hashtag
|
|
526
|
+
);
|
|
527
|
+
if (existing) {
|
|
528
|
+
this.switchTab(existing._id);
|
|
529
|
+
this.showHashtagForm = false;
|
|
530
|
+
this.hashtagInput = "";
|
|
531
|
+
}
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (res.status === 403) {
|
|
536
|
+
this.error = "Session expired — please refresh the page.";
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (!res.ok) return;
|
|
541
|
+
|
|
542
|
+
const newTab = await res.json();
|
|
543
|
+
newTab._id = String(newTab._id);
|
|
544
|
+
this.tabs.push(newTab);
|
|
545
|
+
this.hashtagInput = "";
|
|
546
|
+
this.showHashtagForm = false;
|
|
547
|
+
this.switchTab(newTab._id);
|
|
548
|
+
} catch {
|
|
549
|
+
// Network error — silent
|
|
550
|
+
}
|
|
551
|
+
},
|
|
552
|
+
|
|
553
|
+
// ── Remove a tab ──────────────────────────────────────────────────────────
|
|
554
|
+
|
|
555
|
+
async removeTab(tab) {
|
|
556
|
+
const body =
|
|
557
|
+
tab.type === "instance"
|
|
558
|
+
? { type: "instance", domain: tab.domain, scope: tab.scope }
|
|
559
|
+
: { type: "hashtag", hashtag: tab.hashtag };
|
|
560
|
+
|
|
561
|
+
try {
|
|
562
|
+
const res = await fetch(
|
|
563
|
+
`${this._mountPath}/admin/reader/api/tabs/remove`,
|
|
564
|
+
{
|
|
565
|
+
method: "POST",
|
|
566
|
+
headers: {
|
|
567
|
+
"Content-Type": "application/json",
|
|
568
|
+
"X-CSRF-Token": this._csrfToken,
|
|
569
|
+
},
|
|
570
|
+
body: JSON.stringify(body),
|
|
571
|
+
}
|
|
572
|
+
);
|
|
573
|
+
|
|
574
|
+
if (res.status === 403) {
|
|
575
|
+
this.error = "Session expired — please refresh the page.";
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (!res.ok) return;
|
|
580
|
+
|
|
581
|
+
// Clean up tab state
|
|
582
|
+
const { [tab._id]: _removed, ...remaining } = this.tabState;
|
|
583
|
+
this.tabState = remaining;
|
|
584
|
+
this._cacheOrder = this._cacheOrder.filter((id) => id !== tab._id);
|
|
585
|
+
|
|
586
|
+
this.tabs = this.tabs
|
|
587
|
+
.filter((t) => t._id !== tab._id)
|
|
588
|
+
.map((t, i) => ({ ...t, order: i }));
|
|
589
|
+
|
|
590
|
+
if (this.activeTabId === tab._id) {
|
|
591
|
+
this._teardownScrollObserver();
|
|
592
|
+
this.activeTabId = null;
|
|
593
|
+
}
|
|
594
|
+
} catch {
|
|
595
|
+
// Network error — silent
|
|
596
|
+
}
|
|
597
|
+
},
|
|
598
|
+
|
|
599
|
+
// ── Tab reordering ────────────────────────────────────────────────────────
|
|
600
|
+
|
|
601
|
+
moveUp(tab) {
|
|
602
|
+
const idx = this.tabs.findIndex((t) => t._id === tab._id);
|
|
603
|
+
if (idx <= 0) return;
|
|
604
|
+
const copy = [...this.tabs];
|
|
605
|
+
[copy[idx - 1], copy[idx]] = [copy[idx], copy[idx - 1]];
|
|
606
|
+
this.tabs = copy.map((t, i) => ({ ...t, order: i }));
|
|
607
|
+
this._scheduleReorder();
|
|
608
|
+
},
|
|
609
|
+
|
|
610
|
+
moveDown(tab) {
|
|
611
|
+
const idx = this.tabs.findIndex((t) => t._id === tab._id);
|
|
612
|
+
if (idx < 0 || idx >= this.tabs.length - 1) return;
|
|
613
|
+
const copy = [...this.tabs];
|
|
614
|
+
[copy[idx], copy[idx + 1]] = [copy[idx + 1], copy[idx]];
|
|
615
|
+
this.tabs = copy.map((t, i) => ({ ...t, order: i }));
|
|
616
|
+
this._scheduleReorder();
|
|
617
|
+
},
|
|
618
|
+
|
|
619
|
+
_scheduleReorder() {
|
|
620
|
+
if (this._reorderTimer) clearTimeout(this._reorderTimer);
|
|
621
|
+
this._reorderTimer = setTimeout(() => this._sendReorder(), 500);
|
|
622
|
+
},
|
|
623
|
+
|
|
624
|
+
async _sendReorder() {
|
|
625
|
+
try {
|
|
626
|
+
const tabIds = this.tabs.map((t) => t._id);
|
|
627
|
+
await fetch(
|
|
628
|
+
`${this._mountPath}/admin/reader/api/tabs/reorder`,
|
|
629
|
+
{
|
|
630
|
+
method: "PATCH",
|
|
631
|
+
headers: {
|
|
632
|
+
"Content-Type": "application/json",
|
|
633
|
+
"X-CSRF-Token": this._csrfToken,
|
|
634
|
+
},
|
|
635
|
+
body: JSON.stringify({ tabIds }),
|
|
636
|
+
}
|
|
637
|
+
);
|
|
638
|
+
} catch {
|
|
639
|
+
// Non-critical — reorder failure doesn't affect UX
|
|
640
|
+
}
|
|
641
|
+
},
|
|
642
|
+
}));
|
|
643
|
+
});
|