mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
- Poll every 30s for new items, show sticky "N new posts — Load" banner - IntersectionObserver marks cards as read at 50% visibility, batches to server every 5s - Read cards fade to 70% opacity, full opacity on hover - "Unread" toggle in tab bar filters to unread-only items - New API: GET /api/timeline/count-new, POST /api/timeline/mark-read Confab-Link: http://localhost:8080/sessions/e9d666ac-3c90-4298-9e92-9ac9d142bc06
340 lines
9.4 KiB
JavaScript
340 lines
9.4 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();
|
|
},
|
|
}));
|
|
|
|
/**
|
|
* New posts banner — polls for new items every 30s, shows "N new posts" banner.
|
|
*/
|
|
// eslint-disable-next-line no-undef
|
|
Alpine.data("apNewPostsBanner", () => ({
|
|
count: 0,
|
|
newest: null,
|
|
tab: "",
|
|
mountPath: "",
|
|
_interval: null,
|
|
|
|
init() {
|
|
const el = this.$el;
|
|
this.newest = el.dataset.newest || null;
|
|
this.tab = el.dataset.tab || "notes";
|
|
this.mountPath = el.dataset.mountPath || "";
|
|
|
|
if (!this.newest) return;
|
|
|
|
this._interval = setInterval(() => this.poll(), 30000);
|
|
},
|
|
|
|
async poll() {
|
|
if (!this.newest) return;
|
|
try {
|
|
const params = new URLSearchParams({ after: this.newest, tab: this.tab });
|
|
const res = await fetch(
|
|
`${this.mountPath}/admin/reader/api/timeline/count-new?${params}`,
|
|
{ headers: { Accept: "application/json" } },
|
|
);
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
this.count = data.count || 0;
|
|
} catch {
|
|
// Silently ignore polling errors
|
|
}
|
|
},
|
|
|
|
async loadNew() {
|
|
if (!this.newest || this.count === 0) return;
|
|
try {
|
|
const params = new URLSearchParams({ after: this.newest, tab: this.tab });
|
|
const res = await fetch(
|
|
`${this.mountPath}/admin/reader/api/timeline?${params}`,
|
|
{ headers: { Accept: "application/json" } },
|
|
);
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
|
|
const timeline = document.getElementById("ap-timeline");
|
|
if (data.html && timeline) {
|
|
timeline.insertAdjacentHTML("afterbegin", data.html);
|
|
// Update newest cursor to the first item's published date
|
|
const firstCard = timeline.querySelector(".ap-card");
|
|
if (firstCard) {
|
|
const timeEl = firstCard.querySelector("time[datetime]");
|
|
if (timeEl) this.newest = timeEl.getAttribute("datetime");
|
|
}
|
|
}
|
|
|
|
this.count = 0;
|
|
} catch {
|
|
// Silently ignore load errors
|
|
}
|
|
},
|
|
|
|
destroy() {
|
|
if (this._interval) clearInterval(this._interval);
|
|
},
|
|
}));
|
|
|
|
/**
|
|
* Read tracking — IntersectionObserver marks cards as read on 50% visibility.
|
|
* Batches UIDs and flushes to server every 5 seconds.
|
|
*/
|
|
// eslint-disable-next-line no-undef
|
|
Alpine.data("apReadTracker", () => ({
|
|
_observer: null,
|
|
_batch: [],
|
|
_flushTimer: null,
|
|
_mountPath: "",
|
|
_csrfToken: "",
|
|
|
|
init() {
|
|
const el = this.$el;
|
|
this._mountPath = el.dataset.mountPath || "";
|
|
this._csrfToken = el.dataset.csrfToken || "";
|
|
|
|
this._observer = new IntersectionObserver(
|
|
(entries) => {
|
|
for (const entry of entries) {
|
|
if (entry.isIntersecting) {
|
|
const card = entry.target;
|
|
const uid = card.dataset.uid;
|
|
if (uid && !card.classList.contains("ap-card--read")) {
|
|
card.classList.add("ap-card--read");
|
|
this._batch.push(uid);
|
|
}
|
|
this._observer.unobserve(card);
|
|
}
|
|
}
|
|
},
|
|
{ threshold: 0.5 },
|
|
);
|
|
|
|
// Observe all existing cards
|
|
this._observeCards();
|
|
|
|
// Watch for new cards added by infinite scroll
|
|
this._mutationObserver = new MutationObserver(() => this._observeCards());
|
|
this._mutationObserver.observe(el, { childList: true, subtree: true });
|
|
|
|
// Flush batch every 5 seconds
|
|
this._flushTimer = setInterval(() => this._flush(), 5000);
|
|
},
|
|
|
|
_observeCards() {
|
|
const cards = this.$el.querySelectorAll(".ap-card[data-uid]:not(.ap-card--read)");
|
|
for (const card of cards) {
|
|
this._observer.observe(card);
|
|
}
|
|
},
|
|
|
|
async _flush() {
|
|
if (this._batch.length === 0) return;
|
|
const uids = [...this._batch];
|
|
this._batch = [];
|
|
|
|
try {
|
|
await fetch(`${this._mountPath}/admin/reader/api/timeline/mark-read`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"X-CSRF-Token": this._csrfToken,
|
|
},
|
|
body: JSON.stringify({ uids }),
|
|
});
|
|
} catch {
|
|
// Non-critical — items will be re-marked on next view
|
|
}
|
|
},
|
|
|
|
destroy() {
|
|
if (this._observer) this._observer.disconnect();
|
|
if (this._mutationObserver) this._mutationObserver.disconnect();
|
|
if (this._flushTimer) clearInterval(this._flushTimer);
|
|
this._flush(); // Final flush on teardown
|
|
},
|
|
}));
|
|
});
|