mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
refactor: unify reader and explore processing pipeline (Release 0)
Extract shared item-processing.js module with postProcessItems(), applyModerationFilters(), buildInteractionMap(), applyTabFilter(), renderItemCards(), and loadModerationData(). All controllers (reader, api-timeline, explore, hashtag-explore, tag-timeline) now flow through the same pipeline. Unify Alpine.js infinite scroll into single parameterized apInfiniteScroll component configured via data attributes, replacing the separate apExploreScroll component. Also adds fetchAndStoreQuote() for quote enrichment and on-demand quote fetching in post-detail controller. Bump version to 2.5.0. Confab-Link: http://localhost:8080/sessions/e9d666ac-3c90-4298-9e92-9ac9d142bc06
This commit is contained in:
@@ -1,121 +1,55 @@
|
||||
/**
|
||||
* Infinite scroll — AlpineJS component for AJAX load-more on the timeline
|
||||
* Registers the `apInfiniteScroll` Alpine data component.
|
||||
* Infinite scroll — unified AlpineJS component for AJAX load-more.
|
||||
* Works for both reader timeline and explore view via data attributes.
|
||||
*
|
||||
* Required data attributes on the component element:
|
||||
* data-cursor — initial pagination cursor value
|
||||
* data-api-url — API endpoint URL (e.g., /activitypub/admin/reader/api/timeline)
|
||||
* data-cursor-param — query param name for the cursor (e.g., "before" or "max_id")
|
||||
* data-cursor-field — response JSON field for the next cursor (e.g., "before" or "maxId")
|
||||
* data-timeline-id — DOM ID of the timeline container to append HTML into
|
||||
*
|
||||
* Optional:
|
||||
* data-extra-params — JSON-encoded object of additional query params
|
||||
* data-hide-pagination — CSS selector of no-JS pagination to hide
|
||||
*/
|
||||
|
||||
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: "",
|
||||
cursor: null,
|
||||
apiUrl: "",
|
||||
cursorParam: "before",
|
||||
cursorField: "before",
|
||||
timelineId: "",
|
||||
extraParams: {},
|
||||
observer: null,
|
||||
|
||||
init() {
|
||||
const el = this.$el;
|
||||
this.before = el.dataset.before || null;
|
||||
this.tab = el.dataset.tab || "";
|
||||
this.tag = el.dataset.tag || "";
|
||||
this.cursor = el.dataset.cursor || null;
|
||||
this.apiUrl = el.dataset.apiUrl || "";
|
||||
this.cursorParam = el.dataset.cursorParam || "before";
|
||||
this.cursorField = el.dataset.cursorField || "before";
|
||||
this.timelineId = el.dataset.timelineId || "";
|
||||
|
||||
// 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";
|
||||
// Parse extra params from JSON data attribute
|
||||
try {
|
||||
this.extraParams = JSON.parse(el.dataset.extraParams || "{}");
|
||||
} catch {
|
||||
this.extraParams = {};
|
||||
}
|
||||
|
||||
if (!this.before) {
|
||||
// Hide the no-JS pagination fallback now that JS is active
|
||||
const hideSel = el.dataset.hidePagination;
|
||||
if (hideSel) {
|
||||
const paginationEl = document.getElementById(hideSel);
|
||||
if (paginationEl) paginationEl.style.display = "none";
|
||||
}
|
||||
|
||||
if (!this.cursor) {
|
||||
this.done = true;
|
||||
return;
|
||||
}
|
||||
@@ -129,7 +63,7 @@ document.addEventListener("alpine:init", () => {
|
||||
}
|
||||
}
|
||||
},
|
||||
{ rootMargin: "200px" }
|
||||
{ rootMargin: "200px" },
|
||||
);
|
||||
|
||||
if (this.$refs.sentinel) {
|
||||
@@ -138,36 +72,36 @@ document.addEventListener("alpine:init", () => {
|
||||
},
|
||||
|
||||
async loadMore() {
|
||||
if (this.loading || this.done || !this.before) return;
|
||||
if (this.loading || this.done || !this.cursor) 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);
|
||||
const params = new URLSearchParams({
|
||||
[this.cursorParam]: this.cursor,
|
||||
...this.extraParams,
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${mountPath}/admin/reader/api/timeline?${params.toString()}`,
|
||||
{ headers: { Accept: "application/json" } }
|
||||
`${this.apiUrl}?${params.toString()}`,
|
||||
{ headers: { Accept: "application/json" } },
|
||||
);
|
||||
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
const timeline = this.timelineId
|
||||
? document.getElementById(this.timelineId)
|
||||
: null;
|
||||
|
||||
if (data.html && timeline) {
|
||||
// Append the returned pre-rendered HTML
|
||||
timeline.insertAdjacentHTML("beforeend", data.html);
|
||||
}
|
||||
|
||||
if (data.before) {
|
||||
this.before = data.before;
|
||||
if (data[this.cursorField]) {
|
||||
this.cursor = data[this.cursorField];
|
||||
} else {
|
||||
// No more items
|
||||
this.done = true;
|
||||
if (this.observer) this.observer.disconnect();
|
||||
}
|
||||
@@ -178,10 +112,6 @@ document.addEventListener("alpine:init", () => {
|
||||
}
|
||||
},
|
||||
|
||||
appendItems(/* detail */) {
|
||||
// Custom event hook — not used in this implementation but kept for extensibility
|
||||
},
|
||||
|
||||
destroy() {
|
||||
if (this.observer) this.observer.disconnect();
|
||||
},
|
||||
@@ -282,7 +212,9 @@ document.addEventListener("alpine:init", () => {
|
||||
const card = entry.target;
|
||||
const uid = card.dataset.uid;
|
||||
if (uid && !card.classList.contains("ap-card--read")) {
|
||||
card.classList.add("ap-card--read");
|
||||
// Mark read server-side but DON'T dim visually in this session.
|
||||
// Cards only appear dimmed when they arrive from the server
|
||||
// with item.read=true on a subsequent page load.
|
||||
this._batch.push(uid);
|
||||
}
|
||||
this._observer.unobserve(card);
|
||||
|
||||
@@ -366,6 +366,16 @@ document.addEventListener("alpine:init", () => {
|
||||
}
|
||||
},
|
||||
|
||||
// ── Public load-more method (called by button click) ────────────────────
|
||||
|
||||
loadMoreTab(tab) {
|
||||
if (tab.type === "instance") {
|
||||
this._loadMoreInstanceTab(tab);
|
||||
} else if (tab.type === "hashtag") {
|
||||
this._loadMoreHashtagTab(tab);
|
||||
}
|
||||
},
|
||||
|
||||
// ── Infinite scroll for tab panels ───────────────────────────────────────
|
||||
|
||||
_setupScrollObserver(tab) {
|
||||
|
||||
@@ -2486,15 +2486,21 @@
|
||||
}
|
||||
|
||||
.ap-quote-embed__content {
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 6;
|
||||
color: var(--color-on-background);
|
||||
display: -webkit-box;
|
||||
font-size: var(--font-size-s);
|
||||
line-height: 1.5;
|
||||
max-height: calc(1.5em * 6);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ap-quote-embed__content a {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.ap-quote-embed__content a span {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.ap-quote-embed__content p {
|
||||
margin: 0 0 var(--space-xs);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user