mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
Replace the cramped deck/column layout on the explore page with a tabbed interface. Three tab types: Search (always first), Instance (pinned with local/federated badge), and Hashtag (aggregated across all pinned instances). - New ap_explore_tabs collection replaces ap_decks (clean start) - Tab CRUD API: add, remove, reorder with CSRF/SSRF validation - Per-tab infinite scroll with IntersectionObserver + AbortController - Hashtag tabs query up to 10 instances in parallel, merge by date, deduplicate by URL - WAI-ARIA tabs pattern with arrow key navigation - LRU cache (5 tabs) for tab content - Extract shared explore-utils.js (validators + status mapping) - Remove all old deck code (JS, CSS, controllers, locale strings)
190 lines
5.0 KiB
JavaScript
190 lines
5.0 KiB
JavaScript
/**
|
|
* Infinite scroll — AlpineJS component for AJAX load-more on the timeline
|
|
* Registers the `apInfiniteScroll` Alpine data component.
|
|
*/
|
|
|
|
document.addEventListener("alpine:init", () => {
|
|
// eslint-disable-next-line no-undef
|
|
Alpine.data("apExploreScroll", () => ({
|
|
loading: false,
|
|
done: false,
|
|
maxId: null,
|
|
instance: "",
|
|
scope: "local",
|
|
hashtag: "",
|
|
observer: null,
|
|
|
|
init() {
|
|
const el = this.$el;
|
|
this.maxId = el.dataset.maxId || null;
|
|
this.instance = el.dataset.instance || "";
|
|
this.scope = el.dataset.scope || "local";
|
|
this.hashtag = el.dataset.hashtag || "";
|
|
|
|
if (!this.maxId) {
|
|
this.done = true;
|
|
return;
|
|
}
|
|
|
|
this.observer = new IntersectionObserver(
|
|
(entries) => {
|
|
for (const entry of entries) {
|
|
if (entry.isIntersecting && !this.loading && !this.done) {
|
|
this.loadMore();
|
|
}
|
|
}
|
|
},
|
|
{ rootMargin: "200px" }
|
|
);
|
|
|
|
if (this.$refs.sentinel) {
|
|
this.observer.observe(this.$refs.sentinel);
|
|
}
|
|
},
|
|
|
|
async loadMore() {
|
|
if (this.loading || this.done || !this.maxId) return;
|
|
|
|
this.loading = true;
|
|
|
|
const timeline = document.getElementById("ap-explore-timeline");
|
|
const mountPath = timeline ? timeline.dataset.mountPath : "";
|
|
|
|
const params = new URLSearchParams({
|
|
instance: this.instance,
|
|
scope: this.scope,
|
|
max_id: this.maxId,
|
|
});
|
|
// Pass hashtag when in hashtag mode so infinite scroll stays on tag timeline
|
|
if (this.hashtag) {
|
|
params.set("hashtag", this.hashtag);
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(
|
|
`${mountPath}/admin/reader/api/explore?${params.toString()}`,
|
|
{ headers: { Accept: "application/json" } }
|
|
);
|
|
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
|
|
const data = await res.json();
|
|
|
|
if (data.html && timeline) {
|
|
timeline.insertAdjacentHTML("beforeend", data.html);
|
|
}
|
|
|
|
if (data.maxId) {
|
|
this.maxId = data.maxId;
|
|
} else {
|
|
this.done = true;
|
|
if (this.observer) this.observer.disconnect();
|
|
}
|
|
} catch (err) {
|
|
console.error("[ap-explore-scroll] load failed:", err.message);
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
destroy() {
|
|
if (this.observer) this.observer.disconnect();
|
|
},
|
|
}));
|
|
|
|
// eslint-disable-next-line no-undef
|
|
Alpine.data("apInfiniteScroll", () => ({
|
|
loading: false,
|
|
done: false,
|
|
before: null,
|
|
tab: "",
|
|
tag: "",
|
|
observer: null,
|
|
|
|
init() {
|
|
const el = this.$el;
|
|
this.before = el.dataset.before || null;
|
|
this.tab = el.dataset.tab || "";
|
|
this.tag = el.dataset.tag || "";
|
|
|
|
// Hide the no-JS pagination fallback now that JS is active
|
|
const paginationEl =
|
|
document.getElementById("ap-reader-pagination") ||
|
|
document.getElementById("ap-tag-pagination");
|
|
if (paginationEl) {
|
|
paginationEl.style.display = "none";
|
|
}
|
|
|
|
if (!this.before) {
|
|
this.done = true;
|
|
return;
|
|
}
|
|
|
|
// Set up IntersectionObserver to auto-load when sentinel comes into view
|
|
this.observer = new IntersectionObserver(
|
|
(entries) => {
|
|
for (const entry of entries) {
|
|
if (entry.isIntersecting && !this.loading && !this.done) {
|
|
this.loadMore();
|
|
}
|
|
}
|
|
},
|
|
{ rootMargin: "200px" }
|
|
);
|
|
|
|
if (this.$refs.sentinel) {
|
|
this.observer.observe(this.$refs.sentinel);
|
|
}
|
|
},
|
|
|
|
async loadMore() {
|
|
if (this.loading || this.done || !this.before) return;
|
|
|
|
this.loading = true;
|
|
|
|
const timeline = document.getElementById("ap-timeline");
|
|
const mountPath = timeline ? timeline.dataset.mountPath : "";
|
|
|
|
const params = new URLSearchParams({ before: this.before });
|
|
if (this.tab) params.set("tab", this.tab);
|
|
if (this.tag) params.set("tag", this.tag);
|
|
|
|
try {
|
|
const res = await fetch(
|
|
`${mountPath}/admin/reader/api/timeline?${params.toString()}`,
|
|
{ headers: { Accept: "application/json" } }
|
|
);
|
|
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
|
|
const data = await res.json();
|
|
|
|
if (data.html && timeline) {
|
|
// Append the returned pre-rendered HTML
|
|
timeline.insertAdjacentHTML("beforeend", data.html);
|
|
}
|
|
|
|
if (data.before) {
|
|
this.before = data.before;
|
|
} else {
|
|
// No more items
|
|
this.done = true;
|
|
if (this.observer) this.observer.disconnect();
|
|
}
|
|
} catch (err) {
|
|
console.error("[ap-infinite-scroll] load failed:", err.message);
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
appendItems(/* detail */) {
|
|
// Custom event hook — not used in this implementation but kept for extensibility
|
|
},
|
|
|
|
destroy() {
|
|
if (this.observer) this.observer.disconnect();
|
|
},
|
|
}));
|
|
});
|