mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
feat: replace explore deck layout with full-width tabbed design
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)
This commit is contained in:
@@ -1,212 +0,0 @@
|
|||||||
/**
|
|
||||||
* Deck components — Alpine.js components for the TweetDeck-style deck view.
|
|
||||||
*
|
|
||||||
* Registers:
|
|
||||||
* apDeckToggle — star/favorite button to add/remove a deck on the Search tab
|
|
||||||
* apDeckColumn — single deck column with its own infinite-scroll timeline
|
|
||||||
*/
|
|
||||||
|
|
||||||
document.addEventListener("alpine:init", () => {
|
|
||||||
// ── apDeckToggle ──────────────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// Star/favorite button that adds or removes a deck entry for the current
|
|
||||||
// instance+scope combination.
|
|
||||||
//
|
|
||||||
// Parameters (passed via x-data):
|
|
||||||
// domain — instance hostname (e.g. "mastodon.social")
|
|
||||||
// scope — "local" | "federated"
|
|
||||||
// mountPath — plugin mount path for API URL construction
|
|
||||||
// csrfToken — CSRF token from server session
|
|
||||||
// deckCount — current number of saved decks (for limit enforcement)
|
|
||||||
// initialState — true if this instance+scope is already a deck
|
|
||||||
// eslint-disable-next-line no-undef
|
|
||||||
Alpine.data("apDeckToggle", (domain, scope, mountPath, csrfToken, deckCount, initialState) => ({
|
|
||||||
inDeck: initialState,
|
|
||||||
currentCount: deckCount,
|
|
||||||
loading: false,
|
|
||||||
|
|
||||||
get deckLimitReached() {
|
|
||||||
return this.currentCount >= 8 && !this.inDeck;
|
|
||||||
},
|
|
||||||
|
|
||||||
async toggle() {
|
|
||||||
if (this.loading) return;
|
|
||||||
if (!this.inDeck && this.deckLimitReached) return;
|
|
||||||
|
|
||||||
this.loading = true;
|
|
||||||
try {
|
|
||||||
const url = this.inDeck
|
|
||||||
? `${mountPath}/admin/reader/api/decks/remove`
|
|
||||||
: `${mountPath}/admin/reader/api/decks`;
|
|
||||||
|
|
||||||
const res = await fetch(url, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-CSRF-Token": csrfToken,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ domain, scope }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
this.inDeck = !this.inDeck;
|
|
||||||
// Track actual count so deckLimitReached stays accurate
|
|
||||||
this.currentCount += this.inDeck ? 1 : -1;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Network error — state unchanged, server is source of truth
|
|
||||||
} finally {
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// ── apDeckColumn ─────────────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// Individual deck column component. Fetches timeline from the explore API
|
|
||||||
// and renders it in a scrollable column with infinite scroll.
|
|
||||||
//
|
|
||||||
// Uses its own IntersectionObserver referencing `this.$refs.sentinel`
|
|
||||||
// (NOT apExploreScroll which hardcodes document.getElementById).
|
|
||||||
//
|
|
||||||
// Parameters (passed via x-data):
|
|
||||||
// domain — instance hostname
|
|
||||||
// scope — "local" | "federated"
|
|
||||||
// mountPath — plugin mount path
|
|
||||||
// index — column position (0-based), used for staggered loading delay
|
|
||||||
// csrfToken — CSRF token for remove calls
|
|
||||||
// eslint-disable-next-line no-undef
|
|
||||||
Alpine.data("apDeckColumn", (domain, scope, mountPath, index, csrfToken) => ({
|
|
||||||
itemCount: 0,
|
|
||||||
html: "",
|
|
||||||
maxId: null,
|
|
||||||
loading: false,
|
|
||||||
done: false,
|
|
||||||
error: null,
|
|
||||||
observer: null,
|
|
||||||
abortController: null,
|
|
||||||
|
|
||||||
init() {
|
|
||||||
// Stagger initial fetch: column 0 loads immediately, column N waits N*200ms
|
|
||||||
const delay = index * 200;
|
|
||||||
if (delay === 0) {
|
|
||||||
this.loadMore();
|
|
||||||
} else {
|
|
||||||
setTimeout(() => {
|
|
||||||
this.loadMore();
|
|
||||||
}, delay);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up IntersectionObserver scoped to this column's scrollable body
|
|
||||||
// (root must be the scroll container, not viewport, to avoid premature triggers)
|
|
||||||
this.$nextTick(() => {
|
|
||||||
const root = this.$refs.body || null;
|
|
||||||
this.observer = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (entry.isIntersecting && !this.loading && !this.done && this.itemCount > 0) {
|
|
||||||
this.loadMore();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ root, rootMargin: "200px" },
|
|
||||||
);
|
|
||||||
|
|
||||||
if (this.$refs.sentinel) {
|
|
||||||
this.observer.observe(this.$refs.sentinel);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
if (this.abortController) {
|
|
||||||
this.abortController.abort();
|
|
||||||
this.abortController = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.observer) {
|
|
||||||
this.observer.disconnect();
|
|
||||||
this.observer = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async loadMore() {
|
|
||||||
if (this.loading || this.done) return;
|
|
||||||
|
|
||||||
this.loading = true;
|
|
||||||
this.error = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.abortController = new AbortController();
|
|
||||||
|
|
||||||
const url = new URL(`${mountPath}/admin/reader/api/explore`, window.location.origin);
|
|
||||||
url.searchParams.set("instance", domain);
|
|
||||||
url.searchParams.set("scope", scope);
|
|
||||||
if (this.maxId) url.searchParams.set("max_id", this.maxId);
|
|
||||||
|
|
||||||
const res = await fetch(url.toString(), {
|
|
||||||
headers: { Accept: "application/json" },
|
|
||||||
signal: this.abortController.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (data.html && data.html.trim() !== "") {
|
|
||||||
this.html += data.html;
|
|
||||||
this.itemCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.maxId) {
|
|
||||||
this.maxId = data.maxId;
|
|
||||||
} else {
|
|
||||||
this.done = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no content came back on first load, mark as done
|
|
||||||
if (!data.html || data.html.trim() === "") {
|
|
||||||
this.done = true;
|
|
||||||
}
|
|
||||||
} catch (fetchError) {
|
|
||||||
this.error = fetchError.message || "Could not load timeline";
|
|
||||||
} finally {
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async retryLoad() {
|
|
||||||
this.error = null;
|
|
||||||
this.done = false;
|
|
||||||
await this.loadMore();
|
|
||||||
},
|
|
||||||
|
|
||||||
async removeDeck() {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${mountPath}/admin/reader/api/decks/remove`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-CSRF-Token": csrfToken,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ domain, scope }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
// Remove column from DOM
|
|
||||||
if (this.observer) {
|
|
||||||
this.observer.disconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$el.remove();
|
|
||||||
} else {
|
|
||||||
this.error = `Failed to remove (${res.status})`;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
this.error = "Network error — could not remove column";
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
@@ -11,6 +11,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
maxId: null,
|
maxId: null,
|
||||||
instance: "",
|
instance: "",
|
||||||
scope: "local",
|
scope: "local",
|
||||||
|
hashtag: "",
|
||||||
observer: null,
|
observer: null,
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
@@ -18,6 +19,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.maxId = el.dataset.maxId || null;
|
this.maxId = el.dataset.maxId || null;
|
||||||
this.instance = el.dataset.instance || "";
|
this.instance = el.dataset.instance || "";
|
||||||
this.scope = el.dataset.scope || "local";
|
this.scope = el.dataset.scope || "local";
|
||||||
|
this.hashtag = el.dataset.hashtag || "";
|
||||||
|
|
||||||
if (!this.maxId) {
|
if (!this.maxId) {
|
||||||
this.done = true;
|
this.done = true;
|
||||||
@@ -53,6 +55,10 @@ document.addEventListener("alpine:init", () => {
|
|||||||
scope: this.scope,
|
scope: this.scope,
|
||||||
max_id: this.maxId,
|
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 {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
|
|||||||
643
assets/reader-tabs.js
Normal file
643
assets/reader-tabs.js
Normal file
@@ -0,0 +1,643 @@
|
|||||||
|
/**
|
||||||
|
* Tab components — Alpine.js component for the tabbed explore page.
|
||||||
|
*
|
||||||
|
* Registers:
|
||||||
|
* apExploreTabs — tab management, timeline loading, infinite scroll
|
||||||
|
*
|
||||||
|
* Guard: init() exits early when .ap-explore-tabs-container is absent so
|
||||||
|
* this script is safe to load on all reader pages via the shared layout.
|
||||||
|
*
|
||||||
|
* Configuration is read from data-* attributes on the root element:
|
||||||
|
* data-mount-path — plugin mount path for API URL construction
|
||||||
|
* data-csrf — CSRF token from server session
|
||||||
|
*/
|
||||||
|
|
||||||
|
document.addEventListener("alpine:init", () => {
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
Alpine.data("apExploreTabs", () => ({
|
||||||
|
// ── Tab list and active state ────────────────────────────────────────────
|
||||||
|
tabs: [],
|
||||||
|
activeTabId: null, // null = Search tab; string = user tab _id
|
||||||
|
|
||||||
|
// ── Tab management UI state ──────────────────────────────────────────────
|
||||||
|
pinning: false,
|
||||||
|
showHashtagForm: false,
|
||||||
|
hashtagInput: "",
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
// ── Per-tab content state (keyed by tab _id) ─────────────────────────────
|
||||||
|
// Each entry: { loading, error, html, maxId, done, abortController }
|
||||||
|
// Hashtag tabs additionally carry: { cursors, sourceMeta }
|
||||||
|
// cursors: { [domain]: maxId|null } — per-instance pagination cursors
|
||||||
|
// sourceMeta: { instancesQueried, instancesTotal, instanceLabels }
|
||||||
|
tabState: {},
|
||||||
|
|
||||||
|
// ── Bounded content cache (last 5 tabs, LRU by access order) ────────────
|
||||||
|
_cacheOrder: [],
|
||||||
|
|
||||||
|
// ── Scroll observer for the active tab ───────────────────────────────────
|
||||||
|
_tabObserver: null,
|
||||||
|
|
||||||
|
// ── Configuration (read from data attributes) ────────────────────────────
|
||||||
|
_mountPath: "",
|
||||||
|
_csrfToken: "",
|
||||||
|
_reorderTimer: null,
|
||||||
|
|
||||||
|
// ── Lifecycle ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
init() {
|
||||||
|
if (!document.querySelector(".ap-explore-tabs-container")) return;
|
||||||
|
this._mountPath = this.$el.dataset.mountPath || "";
|
||||||
|
this._csrfToken = this.$el.dataset.csrf || "";
|
||||||
|
this._loadTabs();
|
||||||
|
},
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (this._tabObserver) {
|
||||||
|
this._tabObserver.disconnect();
|
||||||
|
this._tabObserver = null;
|
||||||
|
}
|
||||||
|
if (this._reorderTimer) {
|
||||||
|
clearTimeout(this._reorderTimer);
|
||||||
|
this._reorderTimer = null;
|
||||||
|
}
|
||||||
|
// Abort any in-flight requests
|
||||||
|
for (const state of Object.values(this.tabState)) {
|
||||||
|
if (state.abortController) state.abortController.abort();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async _loadTabs() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${this._mountPath}/admin/reader/api/tabs`,
|
||||||
|
{ headers: { Accept: "application/json" } }
|
||||||
|
);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
this.tabs = data.map((t) => ({ ...t, _id: String(t._id) }));
|
||||||
|
} catch {
|
||||||
|
// Non-critical — tab bar degrades gracefully to Search-only
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Tab content state helpers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
_getState(tabId) {
|
||||||
|
return this.tabState[tabId] || {
|
||||||
|
loading: false, error: null, html: "", maxId: null, done: false,
|
||||||
|
abortController: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
_setState(tabId, update) {
|
||||||
|
const current = this._getState(tabId);
|
||||||
|
this.tabState = { ...this.tabState, [tabId]: { ...current, ...update } };
|
||||||
|
},
|
||||||
|
|
||||||
|
// LRU cache management — evict oldest when cache grows past 5 tabs
|
||||||
|
_touchCache(tabId) {
|
||||||
|
this._cacheOrder = this._cacheOrder.filter((id) => id !== tabId);
|
||||||
|
this._cacheOrder.push(tabId);
|
||||||
|
|
||||||
|
while (this._cacheOrder.length > 5) {
|
||||||
|
const evictId = this._cacheOrder.shift();
|
||||||
|
const evictedState = this.tabState[evictId];
|
||||||
|
if (evictedState) {
|
||||||
|
this._setState(evictId, {
|
||||||
|
html: "", maxId: null, done: false, loading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Tab switching ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
switchToSearch() {
|
||||||
|
this._abortActiveTabFetch();
|
||||||
|
this._teardownScrollObserver();
|
||||||
|
this.activeTabId = null;
|
||||||
|
this.error = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
switchTab(tabId) {
|
||||||
|
if (this.activeTabId === tabId) return;
|
||||||
|
this._abortActiveTabFetch();
|
||||||
|
this._teardownScrollObserver();
|
||||||
|
this.activeTabId = tabId;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
const tab = this.tabs.find((t) => t._id === tabId);
|
||||||
|
if (!tab) return;
|
||||||
|
|
||||||
|
const state = this._getState(tabId);
|
||||||
|
|
||||||
|
if (tab.type === "instance") {
|
||||||
|
if (!state.html && !state.loading) {
|
||||||
|
// Cache miss — load first page
|
||||||
|
this.$nextTick(() => this._loadInstanceTab(tab));
|
||||||
|
} else if (state.html) {
|
||||||
|
// Cache hit — restore scroll observer
|
||||||
|
this._touchCache(tabId);
|
||||||
|
this.$nextTick(() => this._setupScrollObserver(tab));
|
||||||
|
}
|
||||||
|
} else if (tab.type === "hashtag") {
|
||||||
|
if (!state.html && !state.loading) {
|
||||||
|
this.$nextTick(() => this._loadHashtagTab(tab));
|
||||||
|
} else if (state.html) {
|
||||||
|
this._touchCache(tabId);
|
||||||
|
this.$nextTick(() => this._setupScrollObserver(tab));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_abortActiveTabFetch() {
|
||||||
|
if (!this.activeTabId) return;
|
||||||
|
const state = this._getState(this.activeTabId);
|
||||||
|
if (state.abortController) {
|
||||||
|
state.abortController.abort();
|
||||||
|
this._setState(this.activeTabId, {
|
||||||
|
abortController: null,
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Instance tab loading ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async _loadInstanceTab(tab) {
|
||||||
|
const tabId = tab._id;
|
||||||
|
const abortController = new AbortController();
|
||||||
|
this._setState(tabId, {
|
||||||
|
loading: true, error: null, abortController,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(
|
||||||
|
`${this._mountPath}/admin/reader/api/explore`,
|
||||||
|
window.location.origin
|
||||||
|
);
|
||||||
|
url.searchParams.set("instance", tab.domain);
|
||||||
|
url.searchParams.set("scope", tab.scope);
|
||||||
|
|
||||||
|
const res = await fetch(url.toString(), {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
this._setState(tabId, {
|
||||||
|
loading: false,
|
||||||
|
abortController: null,
|
||||||
|
html: data.html || "",
|
||||||
|
maxId: data.maxId || null,
|
||||||
|
done: !data.maxId,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
this._touchCache(tabId);
|
||||||
|
|
||||||
|
// Set up scroll observer after DOM updates
|
||||||
|
this.$nextTick(() => this._setupScrollObserver(tab));
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === "AbortError") return; // Tab was switched away — silent
|
||||||
|
this._setState(tabId, {
|
||||||
|
loading: false,
|
||||||
|
abortController: null,
|
||||||
|
error: err.message || "Could not load timeline",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async _loadMoreInstanceTab(tab) {
|
||||||
|
const tabId = tab._id;
|
||||||
|
const state = this._getState(tabId);
|
||||||
|
if (state.loading || state.done || !state.maxId) return;
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
this._setState(tabId, { loading: true, abortController });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(
|
||||||
|
`${this._mountPath}/admin/reader/api/explore`,
|
||||||
|
window.location.origin
|
||||||
|
);
|
||||||
|
url.searchParams.set("instance", tab.domain);
|
||||||
|
url.searchParams.set("scope", tab.scope);
|
||||||
|
url.searchParams.set("max_id", state.maxId);
|
||||||
|
|
||||||
|
const res = await fetch(url.toString(), {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const current = this._getState(tabId);
|
||||||
|
|
||||||
|
this._setState(tabId, {
|
||||||
|
loading: false,
|
||||||
|
abortController: null,
|
||||||
|
html: current.html + (data.html || ""),
|
||||||
|
maxId: data.maxId || null,
|
||||||
|
done: !data.maxId,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === "AbortError") return;
|
||||||
|
this._setState(tabId, {
|
||||||
|
loading: false,
|
||||||
|
abortController: null,
|
||||||
|
error: err.message || "Could not load more posts",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Hashtag tab loading ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async _loadHashtagTab(tab) {
|
||||||
|
const tabId = tab._id;
|
||||||
|
const abortController = new AbortController();
|
||||||
|
this._setState(tabId, { loading: true, error: null, abortController });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(
|
||||||
|
`${this._mountPath}/admin/reader/api/explore/hashtag`,
|
||||||
|
window.location.origin
|
||||||
|
);
|
||||||
|
url.searchParams.set("hashtag", tab.hashtag);
|
||||||
|
url.searchParams.set("cursors", "{}");
|
||||||
|
|
||||||
|
const res = await fetch(url.toString(), {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
this._setState(tabId, {
|
||||||
|
loading: false,
|
||||||
|
abortController: null,
|
||||||
|
html: data.html || "",
|
||||||
|
cursors: data.cursors || {},
|
||||||
|
sourceMeta: {
|
||||||
|
instancesQueried: data.instancesQueried || 0,
|
||||||
|
instancesTotal: data.instancesTotal || 0,
|
||||||
|
instanceLabels: data.instanceLabels || [],
|
||||||
|
},
|
||||||
|
done: !data.html || Object.values(data.cursors || {}).every((c) => !c),
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
this._touchCache(tabId);
|
||||||
|
this.$nextTick(() => this._setupScrollObserver(tab));
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === "AbortError") return;
|
||||||
|
this._setState(tabId, {
|
||||||
|
loading: false,
|
||||||
|
abortController: null,
|
||||||
|
error: err.message || "Could not load hashtag timeline",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async _loadMoreHashtagTab(tab) {
|
||||||
|
const tabId = tab._id;
|
||||||
|
const state = this._getState(tabId);
|
||||||
|
if (state.loading || state.done) return;
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
this._setState(tabId, { loading: true, abortController });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(
|
||||||
|
`${this._mountPath}/admin/reader/api/explore/hashtag`,
|
||||||
|
window.location.origin
|
||||||
|
);
|
||||||
|
url.searchParams.set("hashtag", tab.hashtag);
|
||||||
|
url.searchParams.set("cursors", JSON.stringify(state.cursors || {}));
|
||||||
|
|
||||||
|
const res = await fetch(url.toString(), {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const current = this._getState(tabId);
|
||||||
|
|
||||||
|
const allCursorsExhausted = Object.values(data.cursors || {}).every(
|
||||||
|
(c) => !c
|
||||||
|
);
|
||||||
|
|
||||||
|
this._setState(tabId, {
|
||||||
|
loading: false,
|
||||||
|
abortController: null,
|
||||||
|
html: current.html + (data.html || ""),
|
||||||
|
cursors: data.cursors || {},
|
||||||
|
done: !data.html || allCursorsExhausted,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === "AbortError") return;
|
||||||
|
this._setState(tabId, {
|
||||||
|
loading: false,
|
||||||
|
abortController: null,
|
||||||
|
error: err.message || "Could not load more posts",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async retryTab(tab) {
|
||||||
|
const tabId = tab._id;
|
||||||
|
this._setState(tabId, {
|
||||||
|
error: null, html: "", maxId: null, done: false,
|
||||||
|
cursors: {}, sourceMeta: null,
|
||||||
|
});
|
||||||
|
if (tab.type === "instance") {
|
||||||
|
await this._loadInstanceTab(tab);
|
||||||
|
} else if (tab.type === "hashtag") {
|
||||||
|
await this._loadHashtagTab(tab);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Infinite scroll for tab panels ───────────────────────────────────────
|
||||||
|
|
||||||
|
_setupScrollObserver(tab) {
|
||||||
|
this._teardownScrollObserver();
|
||||||
|
|
||||||
|
const panel = this.$el.querySelector(`#ap-tab-panel-${tab._id}`);
|
||||||
|
if (!panel) return;
|
||||||
|
|
||||||
|
const sentinel = panel.querySelector(".ap-tab-sentinel");
|
||||||
|
if (!sentinel) return;
|
||||||
|
|
||||||
|
this._tabObserver = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
const state = this._getState(tab._id);
|
||||||
|
if (!state.loading && !state.done) {
|
||||||
|
if (tab.type === "instance" && state.maxId) {
|
||||||
|
this._loadMoreInstanceTab(tab);
|
||||||
|
} else if (tab.type === "hashtag") {
|
||||||
|
this._loadMoreHashtagTab(tab);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: "200px" }
|
||||||
|
);
|
||||||
|
this._tabObserver.observe(sentinel);
|
||||||
|
},
|
||||||
|
|
||||||
|
_teardownScrollObserver() {
|
||||||
|
if (this._tabObserver) {
|
||||||
|
this._tabObserver.disconnect();
|
||||||
|
this._tabObserver = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Tab label helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
tabLabel(tab) {
|
||||||
|
return tab.type === "instance" ? tab.domain : `#${tab.hashtag}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
hashtagSourcesLine(tab) {
|
||||||
|
const state = this._getState(tab._id);
|
||||||
|
const meta = state.sourceMeta;
|
||||||
|
if (!meta || !meta.instancesQueried) return "";
|
||||||
|
const n = meta.instancesQueried;
|
||||||
|
const total = meta.instancesTotal;
|
||||||
|
const labels = meta.instanceLabels || [];
|
||||||
|
const tag = tab.hashtag || "";
|
||||||
|
const suffix = n === 1 ? "instance" : "instances";
|
||||||
|
let line = `Searching #${tag} across ${n} ${suffix}`;
|
||||||
|
if (n < total) {
|
||||||
|
line += ` (${n} of ${total} pinned)`;
|
||||||
|
}
|
||||||
|
if (labels.length > 0) {
|
||||||
|
line += `: ${labels.join(", ")}`;
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Keyboard navigation (WAI-ARIA Tabs pattern) ───────────────────────────
|
||||||
|
|
||||||
|
handleTabKeydown(event, currentIndex) {
|
||||||
|
const total = this.tabs.length + 1;
|
||||||
|
let nextIndex = null;
|
||||||
|
|
||||||
|
if (event.key === "ArrowRight") {
|
||||||
|
event.preventDefault();
|
||||||
|
nextIndex = (currentIndex + 1) % total;
|
||||||
|
} else if (event.key === "ArrowLeft") {
|
||||||
|
event.preventDefault();
|
||||||
|
nextIndex = (currentIndex - 1 + total) % total;
|
||||||
|
} else if (event.key === "Home") {
|
||||||
|
event.preventDefault();
|
||||||
|
nextIndex = 0;
|
||||||
|
} else if (event.key === "End") {
|
||||||
|
event.preventDefault();
|
||||||
|
nextIndex = total - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextIndex !== null) {
|
||||||
|
const tabEls = this.$el.querySelectorAll('[role="tab"]');
|
||||||
|
if (tabEls[nextIndex]) tabEls[nextIndex].focus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Pin current search result as instance tab ─────────────────────────────
|
||||||
|
|
||||||
|
async pinInstance(domain, scope) {
|
||||||
|
if (this.pinning) return;
|
||||||
|
this.pinning = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${this._mountPath}/admin/reader/api/tabs`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-CSRF-Token": this._csrfToken,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ type: "instance", domain, scope }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.status === 409) {
|
||||||
|
const existing = this.tabs.find(
|
||||||
|
(t) => t.type === "instance" && t.domain === domain && t.scope === scope
|
||||||
|
);
|
||||||
|
if (existing) this.switchTab(existing._id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 403) {
|
||||||
|
this.error = "Session expired — please refresh the page.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) return;
|
||||||
|
|
||||||
|
const newTab = await res.json();
|
||||||
|
newTab._id = String(newTab._id);
|
||||||
|
this.tabs.push(newTab);
|
||||||
|
this.switchTab(newTab._id);
|
||||||
|
} catch {
|
||||||
|
// Network error — silent
|
||||||
|
} finally {
|
||||||
|
this.pinning = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Add hashtag tab ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async submitHashtagTab() {
|
||||||
|
const hashtag = (this.hashtagInput || "").replace(/^#+/, "").trim();
|
||||||
|
if (!hashtag) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${this._mountPath}/admin/reader/api/tabs`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-CSRF-Token": this._csrfToken,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ type: "hashtag", hashtag }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.status === 409) {
|
||||||
|
const existing = this.tabs.find(
|
||||||
|
(t) => t.type === "hashtag" && t.hashtag === hashtag
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
this.switchTab(existing._id);
|
||||||
|
this.showHashtagForm = false;
|
||||||
|
this.hashtagInput = "";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 403) {
|
||||||
|
this.error = "Session expired — please refresh the page.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) return;
|
||||||
|
|
||||||
|
const newTab = await res.json();
|
||||||
|
newTab._id = String(newTab._id);
|
||||||
|
this.tabs.push(newTab);
|
||||||
|
this.hashtagInput = "";
|
||||||
|
this.showHashtagForm = false;
|
||||||
|
this.switchTab(newTab._id);
|
||||||
|
} catch {
|
||||||
|
// Network error — silent
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Remove a tab ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async removeTab(tab) {
|
||||||
|
const body =
|
||||||
|
tab.type === "instance"
|
||||||
|
? { type: "instance", domain: tab.domain, scope: tab.scope }
|
||||||
|
: { type: "hashtag", hashtag: tab.hashtag };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${this._mountPath}/admin/reader/api/tabs/remove`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-CSRF-Token": this._csrfToken,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.status === 403) {
|
||||||
|
this.error = "Session expired — please refresh the page.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) return;
|
||||||
|
|
||||||
|
// Clean up tab state
|
||||||
|
const { [tab._id]: _removed, ...remaining } = this.tabState;
|
||||||
|
this.tabState = remaining;
|
||||||
|
this._cacheOrder = this._cacheOrder.filter((id) => id !== tab._id);
|
||||||
|
|
||||||
|
this.tabs = this.tabs
|
||||||
|
.filter((t) => t._id !== tab._id)
|
||||||
|
.map((t, i) => ({ ...t, order: i }));
|
||||||
|
|
||||||
|
if (this.activeTabId === tab._id) {
|
||||||
|
this._teardownScrollObserver();
|
||||||
|
this.activeTabId = null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Network error — silent
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Tab reordering ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
moveUp(tab) {
|
||||||
|
const idx = this.tabs.findIndex((t) => t._id === tab._id);
|
||||||
|
if (idx <= 0) return;
|
||||||
|
const copy = [...this.tabs];
|
||||||
|
[copy[idx - 1], copy[idx]] = [copy[idx], copy[idx - 1]];
|
||||||
|
this.tabs = copy.map((t, i) => ({ ...t, order: i }));
|
||||||
|
this._scheduleReorder();
|
||||||
|
},
|
||||||
|
|
||||||
|
moveDown(tab) {
|
||||||
|
const idx = this.tabs.findIndex((t) => t._id === tab._id);
|
||||||
|
if (idx < 0 || idx >= this.tabs.length - 1) return;
|
||||||
|
const copy = [...this.tabs];
|
||||||
|
[copy[idx], copy[idx + 1]] = [copy[idx + 1], copy[idx]];
|
||||||
|
this.tabs = copy.map((t, i) => ({ ...t, order: i }));
|
||||||
|
this._scheduleReorder();
|
||||||
|
},
|
||||||
|
|
||||||
|
_scheduleReorder() {
|
||||||
|
if (this._reorderTimer) clearTimeout(this._reorderTimer);
|
||||||
|
this._reorderTimer = setTimeout(() => this._sendReorder(), 500);
|
||||||
|
},
|
||||||
|
|
||||||
|
async _sendReorder() {
|
||||||
|
try {
|
||||||
|
const tabIds = this.tabs.map((t) => t._id);
|
||||||
|
await fetch(
|
||||||
|
`${this._mountPath}/admin/reader/api/tabs/reorder`,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-CSRF-Token": this._csrfToken,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ tabIds }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Non-critical — reorder failure doesn't affect UX
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
@@ -2060,189 +2060,294 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Explore: deck toggle button ---------- */
|
/* ==========================================================================
|
||||||
|
Explore: Tabbed Design
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
.ap-explore-deck-toggle {
|
/* Tab bar wrapper: enables position:relative for fade gradient overlay */
|
||||||
display: flex;
|
.ap-explore-tabs-container {
|
||||||
justify-content: flex-end;
|
position: relative;
|
||||||
margin-bottom: var(--space-s);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ap-explore-deck-toggle__btn {
|
/* Tab bar with right-edge fade to indicate horizontal overflow */
|
||||||
align-items: center;
|
.ap-explore-tabs-nav {
|
||||||
background: none;
|
padding-right: var(--space-l);
|
||||||
border: var(--border-width-thin) solid var(--color-outline);
|
position: relative;
|
||||||
border-radius: var(--border-radius-small);
|
}
|
||||||
color: var(--color-on-background);
|
|
||||||
cursor: pointer;
|
.ap-explore-tabs-nav::after {
|
||||||
|
background: linear-gradient(to right, transparent, var(--color-background, #fff) 80%);
|
||||||
|
content: "";
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab wrapper: holds tab button + reorder/close controls together */
|
||||||
|
.ap-tab-wrapper {
|
||||||
|
align-items: stretch;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
font-size: var(--font-size-s);
|
position: relative;
|
||||||
gap: var(--space-2xs);
|
|
||||||
padding: var(--space-xs) var(--space-s);
|
|
||||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ap-explore-deck-toggle__btn:hover:not(:disabled) {
|
/* Show controls on hover or when the tab is active */
|
||||||
background: var(--color-offset);
|
.ap-tab-controls {
|
||||||
}
|
|
||||||
|
|
||||||
.ap-explore-deck-toggle__btn--active {
|
|
||||||
background: var(--color-accent5);
|
|
||||||
border-color: var(--color-accent5);
|
|
||||||
color: var(--color-on-accent, #fff);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ap-explore-deck-toggle__btn--active:hover:not(:disabled) {
|
|
||||||
background: var(--color-accent45);
|
|
||||||
border-color: var(--color-accent45);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ap-explore-deck-toggle__btn:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- Deck grid layout ---------- */
|
|
||||||
|
|
||||||
.ap-deck-grid {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-m);
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
|
|
||||||
margin-top: var(--space-m);
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- Deck column ---------- */
|
|
||||||
|
|
||||||
.ap-deck-column {
|
|
||||||
background: var(--color-offset);
|
|
||||||
border: var(--border-width-thin) solid var(--color-outline);
|
|
||||||
border-radius: var(--border-radius-small);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
max-height: calc(100dvh - 220px);
|
|
||||||
min-height: 200px;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ap-deck-column__header {
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: var(--color-background);
|
display: none;
|
||||||
border-bottom: var(--border-width-thin) solid var(--color-outline);
|
gap: 1px;
|
||||||
display: flex;
|
|
||||||
flex-shrink: 0;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
padding: var(--space-xs) var(--space-s);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ap-deck-column__domain {
|
.ap-tab-wrapper:hover .ap-tab-controls,
|
||||||
|
.ap-tab-wrapper:focus-within .ap-tab-controls {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Individual control buttons (↑ ↓ ×) */
|
||||||
|
.ap-tab-control {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-on-offset);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
line-height: 1;
|
||||||
|
padding: 2px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-tab-control:hover {
|
||||||
|
color: var(--color-on-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-tab-control:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-tab-control--remove {
|
||||||
|
color: var(--color-on-offset);
|
||||||
font-size: var(--font-size-s);
|
font-size: var(--font-size-s);
|
||||||
font-weight: 600;
|
}
|
||||||
min-width: 0;
|
|
||||||
|
.ap-tab-control--remove:hover {
|
||||||
|
color: var(--color-red45);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Truncate long domain names in tab labels */
|
||||||
|
.ap-tab__label {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 150px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ap-deck-column__scope-badge {
|
/* Scope badges on instance tabs */
|
||||||
border-radius: var(--border-radius-small);
|
.ap-tab__badge {
|
||||||
flex-shrink: 0;
|
border-radius: 3px;
|
||||||
font-size: var(--font-size-xs);
|
font-size: 0.65em;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
padding: 2px var(--space-xs);
|
letter-spacing: 0.02em;
|
||||||
|
margin-left: var(--space-xs);
|
||||||
|
padding: 1px 4px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ap-deck-column__scope-badge--local {
|
.ap-tab__badge--local {
|
||||||
background: var(--color-blue10, #dbeafe);
|
background: color-mix(in srgb, var(--color-blue40, #2563eb) 15%, transparent);
|
||||||
color: var(--color-blue50, #1e40af);
|
color: var(--color-blue40, #2563eb);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ap-deck-column__scope-badge--federated {
|
.ap-tab__badge--federated {
|
||||||
background: var(--color-purple10, #ede9fe);
|
background: color-mix(in srgb, var(--color-purple45, #7c3aed) 15%, transparent);
|
||||||
color: var(--color-purple50, #5b21b6);
|
color: var(--color-purple45, #7c3aed);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ap-deck-column__remove {
|
/* +# button for adding hashtag tabs */
|
||||||
background: none;
|
.ap-tab--add {
|
||||||
border: none;
|
font-family: monospace;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline hashtag form that appears when +# is clicked */
|
||||||
|
.ap-tab-add-hashtag {
|
||||||
|
align-items: center;
|
||||||
|
display: inline-flex;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-tab-hashtag-form {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-tab-hashtag-form__prefix {
|
||||||
color: var(--color-on-offset);
|
color: var(--color-on-offset);
|
||||||
cursor: pointer;
|
font-weight: 600;
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
line-height: 1;
|
|
||||||
margin-left: auto;
|
|
||||||
padding: 0 2px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ap-deck-column__remove:hover {
|
.ap-tab-hashtag-form__input {
|
||||||
color: var(--color-red45);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ap-deck-column__body {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: var(--space-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ap-deck-column__loading,
|
|
||||||
.ap-deck-column__loading-more,
|
|
||||||
.ap-deck-column__error,
|
|
||||||
.ap-deck-column__empty,
|
|
||||||
.ap-deck-column__done {
|
|
||||||
color: var(--color-on-offset);
|
|
||||||
font-size: var(--font-size-s);
|
|
||||||
padding: var(--space-s);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ap-deck-column__retry {
|
|
||||||
background: none;
|
|
||||||
border: var(--border-width-thin) solid var(--color-outline);
|
border: var(--border-width-thin) solid var(--color-outline);
|
||||||
border-radius: var(--border-radius-small);
|
border-radius: var(--border-radius-small);
|
||||||
color: var(--color-on-background);
|
font-family: inherit;
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
padding: 2px var(--space-s);
|
||||||
|
width: 8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-tab-hashtag-form__input:focus {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-tab-hashtag-form__btn {
|
||||||
|
background: var(--color-primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius-small);
|
||||||
|
color: var(--color-on-primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
font-size: var(--font-size-s);
|
font-size: var(--font-size-s);
|
||||||
margin-top: var(--space-xs);
|
padding: 2px var(--space-s);
|
||||||
padding: var(--space-xs) var(--space-s);
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ap-deck-column__retry:hover {
|
.ap-tab-hashtag-form__btn:hover {
|
||||||
background: var(--color-offset);
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Cards inside deck columns are more compact */
|
/* "Pin as tab" button in search results area */
|
||||||
.ap-deck-column__items .ap-item-card {
|
.ap-explore-pin-bar {
|
||||||
font-size: var(--font-size-s);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- Deck empty state ---------- */
|
|
||||||
|
|
||||||
.ap-deck-empty {
|
|
||||||
margin-top: var(--space-xl);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ap-deck-empty p {
|
|
||||||
color: var(--color-on-offset);
|
|
||||||
font-size: var(--font-size-s);
|
|
||||||
margin-bottom: var(--space-s);
|
margin-bottom: var(--space-s);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ap-deck-empty__link {
|
.ap-explore-pin-btn {
|
||||||
|
background: none;
|
||||||
|
border: var(--border-width-thin) solid var(--color-primary);
|
||||||
|
border-radius: var(--border-radius-small);
|
||||||
|
color: var(--color-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
padding: var(--space-xs) var(--space-m);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-explore-pin-btn:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-explore-pin-btn:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hashtag form row inside the search form */
|
||||||
|
.ap-explore-form__hashtag-row {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
margin-top: var(--space-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-explore-form__hashtag-label {
|
||||||
|
color: var(--color-on-offset);
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-explore-form__hashtag-prefix {
|
||||||
|
color: var(--color-on-offset);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-explore-form__hashtag-hint {
|
||||||
|
color: var(--color-on-offset);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-explore-form__input--hashtag {
|
||||||
|
max-width: 200px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab panel containers */
|
||||||
|
.ap-explore-instance-panel,
|
||||||
|
.ap-explore-hashtag-panel {
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading state */
|
||||||
|
.ap-explore-tab-loading {
|
||||||
|
align-items: center;
|
||||||
|
color: var(--color-on-offset);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-explore-tab-loading--more {
|
||||||
|
padding-block: var(--space-m);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-explore-tab-loading__text {
|
||||||
font-size: var(--font-size-s);
|
font-size: var(--font-size-s);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Deck responsive ---------- */
|
/* Error state */
|
||||||
|
.ap-explore-tab-error {
|
||||||
@media (max-width: 767px) {
|
align-items: center;
|
||||||
.ap-deck-grid {
|
display: flex;
|
||||||
grid-template-columns: 1fr;
|
flex-direction: column;
|
||||||
}
|
gap: var(--space-s);
|
||||||
|
padding: var(--space-xl);
|
||||||
.ap-deck-column {
|
|
||||||
max-height: 60vh;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ap-explore-tab-error__message {
|
||||||
|
color: var(--color-red45);
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-explore-tab-error__retry {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-accent);
|
||||||
|
border-radius: var(--border-radius-small);
|
||||||
|
color: var(--color-accent);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
padding: var(--space-xs) var(--space-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-explore-tab-error__retry:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.ap-explore-tab-empty {
|
||||||
|
color: var(--color-on-offset);
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
padding: var(--space-xl);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Infinite scroll sentinel — zero height, invisible */
|
||||||
|
.ap-tab-sentinel {
|
||||||
|
height: 1px;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hashtag tab sources info line */
|
||||||
|
.ap-hashtag-sources {
|
||||||
|
color: var(--color-on-offset);
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--space-s) 0 var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
40
index.js
40
index.js
@@ -70,10 +70,12 @@ import {
|
|||||||
} from "./lib/controllers/explore.js";
|
} from "./lib/controllers/explore.js";
|
||||||
import { followTagController, unfollowTagController } from "./lib/controllers/follow-tag.js";
|
import { followTagController, unfollowTagController } from "./lib/controllers/follow-tag.js";
|
||||||
import {
|
import {
|
||||||
listDecksController,
|
listTabsController,
|
||||||
addDeckController,
|
addTabController,
|
||||||
removeDeckController,
|
removeTabController,
|
||||||
} from "./lib/controllers/decks.js";
|
reorderTabsController,
|
||||||
|
} from "./lib/controllers/tabs.js";
|
||||||
|
import { hashtagExploreApiController } from "./lib/controllers/hashtag-explore.js";
|
||||||
import { publicProfileController } from "./lib/controllers/public-profile.js";
|
import { publicProfileController } from "./lib/controllers/public-profile.js";
|
||||||
import { authorizeInteractionController } from "./lib/controllers/authorize-interaction.js";
|
import { authorizeInteractionController } from "./lib/controllers/authorize-interaction.js";
|
||||||
import { myProfileController } from "./lib/controllers/my-profile.js";
|
import { myProfileController } from "./lib/controllers/my-profile.js";
|
||||||
@@ -238,12 +240,14 @@ export default class ActivityPubEndpoint {
|
|||||||
router.get("/admin/reader/api/timeline", apiTimelineController(mp));
|
router.get("/admin/reader/api/timeline", apiTimelineController(mp));
|
||||||
router.get("/admin/reader/explore", exploreController(mp));
|
router.get("/admin/reader/explore", exploreController(mp));
|
||||||
router.get("/admin/reader/api/explore", exploreApiController(mp));
|
router.get("/admin/reader/api/explore", exploreApiController(mp));
|
||||||
|
router.get("/admin/reader/api/explore/hashtag", hashtagExploreApiController(mp));
|
||||||
router.get("/admin/reader/api/instances", instanceSearchApiController(mp));
|
router.get("/admin/reader/api/instances", instanceSearchApiController(mp));
|
||||||
router.get("/admin/reader/api/instance-check", instanceCheckApiController(mp));
|
router.get("/admin/reader/api/instance-check", instanceCheckApiController(mp));
|
||||||
router.get("/admin/reader/api/popular-accounts", popularAccountsApiController(mp));
|
router.get("/admin/reader/api/popular-accounts", popularAccountsApiController(mp));
|
||||||
router.get("/admin/reader/api/decks", listDecksController(mp));
|
router.get("/admin/reader/api/tabs", listTabsController(mp));
|
||||||
router.post("/admin/reader/api/decks", addDeckController(mp));
|
router.post("/admin/reader/api/tabs", addTabController(mp));
|
||||||
router.post("/admin/reader/api/decks/remove", removeDeckController(mp));
|
router.post("/admin/reader/api/tabs/remove", removeTabController(mp));
|
||||||
|
router.patch("/admin/reader/api/tabs/reorder", reorderTabsController(mp));
|
||||||
router.post("/admin/reader/follow-tag", followTagController(mp));
|
router.post("/admin/reader/follow-tag", followTagController(mp));
|
||||||
router.post("/admin/reader/unfollow-tag", unfollowTagController(mp));
|
router.post("/admin/reader/unfollow-tag", unfollowTagController(mp));
|
||||||
router.get("/admin/reader/notifications", notificationsController(mp));
|
router.get("/admin/reader/notifications", notificationsController(mp));
|
||||||
@@ -884,8 +888,8 @@ export default class ActivityPubEndpoint {
|
|||||||
Indiekit.addCollection("ap_interactions");
|
Indiekit.addCollection("ap_interactions");
|
||||||
Indiekit.addCollection("ap_notes");
|
Indiekit.addCollection("ap_notes");
|
||||||
Indiekit.addCollection("ap_followed_tags");
|
Indiekit.addCollection("ap_followed_tags");
|
||||||
// Deck collections
|
// Explore tab collections
|
||||||
Indiekit.addCollection("ap_decks");
|
Indiekit.addCollection("ap_explore_tabs");
|
||||||
|
|
||||||
// Store collection references (posts resolved lazily)
|
// Store collection references (posts resolved lazily)
|
||||||
const indiekitCollections = Indiekit.collections;
|
const indiekitCollections = Indiekit.collections;
|
||||||
@@ -906,8 +910,8 @@ export default class ActivityPubEndpoint {
|
|||||||
ap_interactions: indiekitCollections.get("ap_interactions"),
|
ap_interactions: indiekitCollections.get("ap_interactions"),
|
||||||
ap_notes: indiekitCollections.get("ap_notes"),
|
ap_notes: indiekitCollections.get("ap_notes"),
|
||||||
ap_followed_tags: indiekitCollections.get("ap_followed_tags"),
|
ap_followed_tags: indiekitCollections.get("ap_followed_tags"),
|
||||||
// Deck collections
|
// Explore tab collections
|
||||||
ap_decks: indiekitCollections.get("ap_decks"),
|
ap_explore_tabs: indiekitCollections.get("ap_explore_tabs"),
|
||||||
get posts() {
|
get posts() {
|
||||||
return indiekitCollections.get("posts");
|
return indiekitCollections.get("posts");
|
||||||
},
|
},
|
||||||
@@ -1032,11 +1036,19 @@ export default class ActivityPubEndpoint {
|
|||||||
{ background: true },
|
{ background: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Deck index — compound unique ensures same instance can appear at most once per scope
|
// Explore tab indexes
|
||||||
this._collections.ap_decks.createIndex(
|
// Compound unique on (type, domain, scope, hashtag) prevents duplicate tabs.
|
||||||
{ domain: 1, scope: 1 },
|
// ALL insertions must explicitly set all four fields (unused fields = null)
|
||||||
|
// because MongoDB treats missing fields differently from null in unique indexes.
|
||||||
|
this._collections.ap_explore_tabs.createIndex(
|
||||||
|
{ type: 1, domain: 1, scope: 1, hashtag: 1 },
|
||||||
{ unique: true, background: true },
|
{ unique: true, background: true },
|
||||||
);
|
);
|
||||||
|
// Order index for efficient sorting of tab bar
|
||||||
|
this._collections.ap_explore_tabs.createIndex(
|
||||||
|
{ order: 1 },
|
||||||
|
{ background: true },
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// Index creation failed — collections not yet available.
|
// Index creation failed — collections not yet available.
|
||||||
// Indexes already exist from previous startups; non-fatal.
|
// Indexes already exist from previous startups; non-fatal.
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
/**
|
|
||||||
* Deck CRUD controller — manages favorited instance deck entries.
|
|
||||||
* Stored in the ap_decks MongoDB collection.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { validateToken } from "../csrf.js";
|
|
||||||
import { validateInstance } from "./explore.js";
|
|
||||||
|
|
||||||
const MAX_DECKS = 8;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /admin/reader/api/decks
|
|
||||||
* Returns all deck entries sorted by addedAt ascending.
|
|
||||||
*/
|
|
||||||
export function listDecksController(_mountPath) {
|
|
||||||
return async (request, response, next) => {
|
|
||||||
try {
|
|
||||||
const { application } = request.app.locals;
|
|
||||||
const collection = application?.collections?.get("ap_decks");
|
|
||||||
if (!collection) {
|
|
||||||
return response.json([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const decks = await collection
|
|
||||||
.find({}, { projection: { _id: 0 } })
|
|
||||||
.sort({ addedAt: 1 })
|
|
||||||
.toArray();
|
|
||||||
|
|
||||||
return response.json(decks);
|
|
||||||
} catch (error) {
|
|
||||||
return next(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /admin/reader/api/decks
|
|
||||||
* Adds a new deck entry for the given domain + scope.
|
|
||||||
* Body: { domain, scope }
|
|
||||||
*/
|
|
||||||
export function addDeckController(_mountPath) {
|
|
||||||
return async (request, response, next) => {
|
|
||||||
try {
|
|
||||||
// CSRF protection
|
|
||||||
if (!validateToken(request)) {
|
|
||||||
return response.status(403).json({ error: "Invalid CSRF token" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { application } = request.app.locals;
|
|
||||||
const collection = application?.collections?.get("ap_decks");
|
|
||||||
if (!collection) {
|
|
||||||
return response.status(500).json({ error: "Deck storage unavailable" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { domain: rawDomain, scope: rawScope } = request.body;
|
|
||||||
|
|
||||||
// Validate domain (SSRF prevention)
|
|
||||||
const domain = validateInstance(rawDomain);
|
|
||||||
if (!domain) {
|
|
||||||
return response.status(400).json({ error: "Invalid instance domain" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate scope
|
|
||||||
const scope = rawScope === "federated" ? "federated" : "local";
|
|
||||||
|
|
||||||
// Enforce max deck limit
|
|
||||||
const count = await collection.countDocuments();
|
|
||||||
if (count >= MAX_DECKS) {
|
|
||||||
return response.status(400).json({
|
|
||||||
error: `Maximum of ${MAX_DECKS} decks reached`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert (unique index on domain+scope will throw on duplicate)
|
|
||||||
const deck = {
|
|
||||||
domain,
|
|
||||||
scope,
|
|
||||||
addedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await collection.insertOne(deck);
|
|
||||||
} catch (insertError) {
|
|
||||||
if (insertError.code === 11_000) {
|
|
||||||
// Duplicate key — deck already exists
|
|
||||||
return response.status(409).json({
|
|
||||||
error: "Deck already exists for this domain and scope",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
throw insertError;
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.status(201).json(deck);
|
|
||||||
} catch (error) {
|
|
||||||
return next(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /admin/reader/api/decks/remove
|
|
||||||
* Removes the deck entry for the given domain + scope.
|
|
||||||
* Body: { domain, scope }
|
|
||||||
*/
|
|
||||||
export function removeDeckController(_mountPath) {
|
|
||||||
return async (request, response, next) => {
|
|
||||||
try {
|
|
||||||
// CSRF protection
|
|
||||||
if (!validateToken(request)) {
|
|
||||||
return response.status(403).json({ error: "Invalid CSRF token" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { application } = request.app.locals;
|
|
||||||
const collection = application?.collections?.get("ap_decks");
|
|
||||||
if (!collection) {
|
|
||||||
return response.status(500).json({ error: "Deck storage unavailable" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { domain: rawDomain, scope: rawScope } = request.body;
|
|
||||||
|
|
||||||
// Validate domain (SSRF prevention)
|
|
||||||
const domain = validateInstance(rawDomain);
|
|
||||||
if (!domain) {
|
|
||||||
return response.status(400).json({ error: "Invalid instance domain" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const scope = rawScope === "federated" ? "federated" : "local";
|
|
||||||
|
|
||||||
await collection.deleteOne({ domain, scope });
|
|
||||||
|
|
||||||
return response.json({ success: true });
|
|
||||||
} catch (error) {
|
|
||||||
return next(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
122
lib/controllers/explore-utils.js
Normal file
122
lib/controllers/explore-utils.js
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Shared utilities for explore controllers.
|
||||||
|
*
|
||||||
|
* Extracted to break the circular dependency between explore.js and tabs.js:
|
||||||
|
* - explore.js needs validateHashtag (was in tabs.js)
|
||||||
|
* - tabs.js needs validateInstance (was in explore.js)
|
||||||
|
* - hashtag-explore.js needs mapMastodonStatusToItem (was duplicated)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import sanitizeHtml from "sanitize-html";
|
||||||
|
import { sanitizeContent } from "../timeline-store.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the instance parameter to prevent SSRF.
|
||||||
|
* Only allows hostnames — no IPs, no localhost, no port numbers.
|
||||||
|
* @param {string} instance - Raw instance parameter from query string
|
||||||
|
* @returns {string|null} Validated hostname or null
|
||||||
|
*/
|
||||||
|
export function validateInstance(instance) {
|
||||||
|
if (!instance || typeof instance !== "string") return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(`https://${instance.trim()}`);
|
||||||
|
const hostname = url.hostname;
|
||||||
|
if (
|
||||||
|
hostname === "localhost" ||
|
||||||
|
hostname === "127.0.0.1" ||
|
||||||
|
hostname === "0.0.0.0" ||
|
||||||
|
hostname === "::1" ||
|
||||||
|
hostname.startsWith("192.168.") ||
|
||||||
|
hostname.startsWith("10.") ||
|
||||||
|
hostname.startsWith("169.254.") ||
|
||||||
|
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname) ||
|
||||||
|
/^[0-9]{1,3}(\.[0-9]{1,3}){3}$/.test(hostname) ||
|
||||||
|
hostname.includes("[")
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hostname;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a hashtag value.
|
||||||
|
* Returns the cleaned hashtag (leading # stripped) or null if invalid.
|
||||||
|
*
|
||||||
|
* Rules match Mastodon's hashtag character rules:
|
||||||
|
* - Alphanumeric + underscore only (\w+)
|
||||||
|
* - 1–100 characters after stripping leading #
|
||||||
|
*/
|
||||||
|
export function validateHashtag(raw) {
|
||||||
|
if (typeof raw !== "string") return null;
|
||||||
|
const cleaned = raw.replace(/^#+/, "");
|
||||||
|
if (!cleaned || cleaned.length > 100) return null;
|
||||||
|
if (!/^[\w]+$/.test(cleaned)) return null;
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a Mastodon API status object to our timeline item format.
|
||||||
|
* @param {object} status - Mastodon API status
|
||||||
|
* @param {string} instance - Instance hostname (for handle construction)
|
||||||
|
* @returns {object} Timeline item compatible with ap-item-card.njk
|
||||||
|
*/
|
||||||
|
export function mapMastodonStatusToItem(status, instance) {
|
||||||
|
const account = status.account || {};
|
||||||
|
const acct = account.acct || "";
|
||||||
|
const handle = acct.includes("@") ? `@${acct}` : `@${acct}@${instance}`;
|
||||||
|
|
||||||
|
const mentions = (status.mentions || []).map((m) => ({
|
||||||
|
name: m.acct.includes("@") ? m.acct : `${m.acct}@${instance}`,
|
||||||
|
url: m.url || "",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const category = (status.tags || []).map((t) => t.name || "");
|
||||||
|
|
||||||
|
const photo = [];
|
||||||
|
const video = [];
|
||||||
|
const audio = [];
|
||||||
|
for (const att of status.media_attachments || []) {
|
||||||
|
const url = att.url || att.remote_url || "";
|
||||||
|
if (!url) continue;
|
||||||
|
if (att.type === "image" || att.type === "gifv") {
|
||||||
|
photo.push(url);
|
||||||
|
} else if (att.type === "video") {
|
||||||
|
video.push(url);
|
||||||
|
} else if (att.type === "audio") {
|
||||||
|
audio.push(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
uid: status.url || status.uri || "",
|
||||||
|
url: status.url || status.uri || "",
|
||||||
|
type: "note",
|
||||||
|
name: "",
|
||||||
|
content: {
|
||||||
|
text: (status.content || "").replace(/<[^>]*>/g, ""),
|
||||||
|
html: sanitizeContent(status.content || ""),
|
||||||
|
},
|
||||||
|
summary: status.spoiler_text || "",
|
||||||
|
sensitive: status.sensitive || false,
|
||||||
|
published: status.created_at || new Date().toISOString(),
|
||||||
|
author: {
|
||||||
|
name: sanitizeHtml(account.display_name || account.username || "Unknown", { allowedTags: [], allowedAttributes: {} }),
|
||||||
|
url: account.url || "",
|
||||||
|
photo: account.avatar || account.avatar_static || "",
|
||||||
|
handle,
|
||||||
|
},
|
||||||
|
category,
|
||||||
|
mentions,
|
||||||
|
photo,
|
||||||
|
video,
|
||||||
|
audio,
|
||||||
|
inReplyTo: status.in_reply_to_id ? `https://${instance}/web/statuses/${status.in_reply_to_id}` : "",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
_explore: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,117 +5,15 @@
|
|||||||
* Remote HTML is always passed through sanitizeContent() before storage.
|
* Remote HTML is always passed through sanitizeContent() before storage.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import sanitizeHtml from "sanitize-html";
|
|
||||||
import { sanitizeContent } from "../timeline-store.js";
|
|
||||||
import { searchInstances, checkInstanceTimeline, getPopularAccounts } from "../fedidb.js";
|
import { searchInstances, checkInstanceTimeline, getPopularAccounts } from "../fedidb.js";
|
||||||
import { getToken } from "../csrf.js";
|
import { getToken } from "../csrf.js";
|
||||||
|
import { validateInstance, validateHashtag, mapMastodonStatusToItem } from "./explore-utils.js";
|
||||||
|
|
||||||
const FETCH_TIMEOUT_MS = 10_000;
|
const FETCH_TIMEOUT_MS = 10_000;
|
||||||
const MAX_RESULTS = 20;
|
const MAX_RESULTS = 20;
|
||||||
|
|
||||||
/**
|
// Re-export validateInstance for backward compatibility (used by tabs.js, index.js)
|
||||||
* Validate the instance parameter to prevent SSRF.
|
export { validateInstance } from "./explore-utils.js";
|
||||||
* Only allows hostnames — no IPs, no localhost, no port numbers for exotic attacks.
|
|
||||||
* @param {string} instance - Raw instance parameter from query string
|
|
||||||
* @returns {string|null} Validated hostname or null
|
|
||||||
*/
|
|
||||||
export function validateInstance(instance) {
|
|
||||||
if (!instance || typeof instance !== "string") return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Prepend https:// to parse as URL
|
|
||||||
const url = new URL(`https://${instance.trim()}`);
|
|
||||||
|
|
||||||
// Must be a plain hostname — no IP addresses, no localhost
|
|
||||||
const hostname = url.hostname;
|
|
||||||
if (
|
|
||||||
hostname === "localhost" ||
|
|
||||||
hostname === "127.0.0.1" ||
|
|
||||||
hostname === "0.0.0.0" ||
|
|
||||||
hostname === "::1" ||
|
|
||||||
hostname.startsWith("192.168.") ||
|
|
||||||
hostname.startsWith("10.") ||
|
|
||||||
hostname.startsWith("169.254.") ||
|
|
||||||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname) ||
|
|
||||||
/^[0-9]{1,3}(\.[0-9]{1,3}){3}$/.test(hostname) || // IPv4
|
|
||||||
hostname.includes("[") // IPv6
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only allow the hostname (no path, no port override)
|
|
||||||
return hostname;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map a Mastodon API status object to our timeline item format.
|
|
||||||
* @param {object} status - Mastodon API status
|
|
||||||
* @param {string} instance - Instance hostname (for handle construction)
|
|
||||||
* @returns {object} Timeline item compatible with ap-item-card.njk
|
|
||||||
*/
|
|
||||||
function mapMastodonStatusToItem(status, instance) {
|
|
||||||
const account = status.account || {};
|
|
||||||
const acct = account.acct || "";
|
|
||||||
// Mastodon acct is "user" for local, "user@remote" for remote
|
|
||||||
const handle = acct.includes("@") ? `@${acct}` : `@${acct}@${instance}`;
|
|
||||||
|
|
||||||
// Map mentions — store without leading @ (template prepends it)
|
|
||||||
const mentions = (status.mentions || []).map((m) => ({
|
|
||||||
name: m.acct.includes("@") ? m.acct : `${m.acct}@${instance}`,
|
|
||||||
url: m.url || "",
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Map hashtags
|
|
||||||
const category = (status.tags || []).map((t) => t.name || "");
|
|
||||||
|
|
||||||
// Map media attachments
|
|
||||||
const photo = [];
|
|
||||||
const video = [];
|
|
||||||
const audio = [];
|
|
||||||
for (const att of status.media_attachments || []) {
|
|
||||||
const url = att.url || att.remote_url || "";
|
|
||||||
if (!url) continue;
|
|
||||||
if (att.type === "image" || att.type === "gifv") {
|
|
||||||
photo.push(url);
|
|
||||||
} else if (att.type === "video") {
|
|
||||||
video.push(url);
|
|
||||||
} else if (att.type === "audio") {
|
|
||||||
audio.push(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
uid: status.url || status.uri || "",
|
|
||||||
url: status.url || status.uri || "",
|
|
||||||
type: "note",
|
|
||||||
name: "",
|
|
||||||
content: {
|
|
||||||
text: (status.content || "").replace(/<[^>]*>/g, ""),
|
|
||||||
html: sanitizeContent(status.content || ""),
|
|
||||||
},
|
|
||||||
summary: status.spoiler_text || "",
|
|
||||||
sensitive: status.sensitive || false,
|
|
||||||
published: status.created_at || new Date().toISOString(),
|
|
||||||
author: {
|
|
||||||
name: sanitizeHtml(account.display_name || account.username || "Unknown", { allowedTags: [], allowedAttributes: {} }),
|
|
||||||
url: account.url || "",
|
|
||||||
photo: account.avatar || account.avatar_static || "",
|
|
||||||
handle,
|
|
||||||
},
|
|
||||||
category,
|
|
||||||
mentions,
|
|
||||||
photo,
|
|
||||||
video,
|
|
||||||
audio,
|
|
||||||
inReplyTo: status.in_reply_to_id ? `https://${instance}/web/statuses/${status.in_reply_to_id}` : "",
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
// Explore-specific: track source instance
|
|
||||||
_explore: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function exploreController(mountPath) {
|
export function exploreController(mountPath) {
|
||||||
return async (request, response, next) => {
|
return async (request, response, next) => {
|
||||||
@@ -123,24 +21,10 @@ export function exploreController(mountPath) {
|
|||||||
const rawInstance = request.query.instance || "";
|
const rawInstance = request.query.instance || "";
|
||||||
const scope = request.query.scope === "federated" ? "federated" : "local";
|
const scope = request.query.scope === "federated" ? "federated" : "local";
|
||||||
const maxId = request.query.max_id || "";
|
const maxId = request.query.max_id || "";
|
||||||
const activeTab = request.query.tab === "decks" ? "decks" : "search";
|
const rawHashtag = request.query.hashtag || "";
|
||||||
|
const hashtag = rawHashtag ? validateHashtag(rawHashtag) : null;
|
||||||
// Fetch deck list for both tabs (needed for star button state + deck tab)
|
|
||||||
const { application } = request.app.locals;
|
|
||||||
const decksCollection = application?.collections?.get("ap_decks");
|
|
||||||
let decks = [];
|
|
||||||
try {
|
|
||||||
decks = await decksCollection
|
|
||||||
.find({})
|
|
||||||
.sort({ addedAt: 1 })
|
|
||||||
.toArray();
|
|
||||||
} catch {
|
|
||||||
// Collection unavailable — non-fatal, decks defaults to []
|
|
||||||
}
|
|
||||||
|
|
||||||
const csrfToken = getToken(request.session);
|
const csrfToken = getToken(request.session);
|
||||||
const deckCount = decks.length;
|
|
||||||
|
|
||||||
const readerParent = { href: `${mountPath}/admin/reader`, text: response.locals.__("activitypub.reader.title") };
|
const readerParent = { href: `${mountPath}/admin/reader`, text: response.locals.__("activitypub.reader.title") };
|
||||||
|
|
||||||
// No instance specified — render clean initial page (no error)
|
// No instance specified — render clean initial page (no error)
|
||||||
@@ -150,14 +34,11 @@ export function exploreController(mountPath) {
|
|||||||
readerParent,
|
readerParent,
|
||||||
instance: "",
|
instance: "",
|
||||||
scope,
|
scope,
|
||||||
|
hashtag: hashtag || "",
|
||||||
items: [],
|
items: [],
|
||||||
maxId: null,
|
maxId: null,
|
||||||
error: null,
|
error: null,
|
||||||
mountPath,
|
mountPath,
|
||||||
activeTab,
|
|
||||||
decks,
|
|
||||||
deckCount,
|
|
||||||
isInDeck: false,
|
|
||||||
csrfToken,
|
csrfToken,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -169,22 +50,25 @@ export function exploreController(mountPath) {
|
|||||||
readerParent,
|
readerParent,
|
||||||
instance: rawInstance,
|
instance: rawInstance,
|
||||||
scope,
|
scope,
|
||||||
|
hashtag: hashtag || "",
|
||||||
items: [],
|
items: [],
|
||||||
maxId: null,
|
maxId: null,
|
||||||
error: response.locals.__("activitypub.reader.explore.invalidInstance"),
|
error: response.locals.__("activitypub.reader.explore.invalidInstance"),
|
||||||
mountPath,
|
mountPath,
|
||||||
activeTab,
|
|
||||||
decks,
|
|
||||||
deckCount,
|
|
||||||
isInDeck: false,
|
|
||||||
csrfToken,
|
csrfToken,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch public timeline from remote instance
|
// Build API URL: hashtag timeline or public timeline
|
||||||
const isLocal = scope === "local";
|
const isLocal = scope === "local";
|
||||||
const apiUrl = new URL(`https://${instance}/api/v1/timelines/public`);
|
let apiUrl;
|
||||||
apiUrl.searchParams.set("local", isLocal ? "true" : "false");
|
if (hashtag) {
|
||||||
|
apiUrl = new URL(`https://${instance}/api/v1/timelines/tag/${encodeURIComponent(hashtag)}`);
|
||||||
|
apiUrl.searchParams.set("local", isLocal ? "true" : "false");
|
||||||
|
} else {
|
||||||
|
apiUrl = new URL(`https://${instance}/api/v1/timelines/public`);
|
||||||
|
apiUrl.searchParams.set("local", isLocal ? "true" : "false");
|
||||||
|
}
|
||||||
apiUrl.searchParams.set("limit", String(MAX_RESULTS));
|
apiUrl.searchParams.set("limit", String(MAX_RESULTS));
|
||||||
if (maxId) apiUrl.searchParams.set("max_id", maxId);
|
if (maxId) apiUrl.searchParams.set("max_id", maxId);
|
||||||
|
|
||||||
@@ -226,23 +110,16 @@ export function exploreController(mountPath) {
|
|||||||
error = msg;
|
error = msg;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isInDeck = decks.some(
|
|
||||||
(d) => d.domain === instance && d.scope === scope,
|
|
||||||
);
|
|
||||||
|
|
||||||
response.render("activitypub-explore", {
|
response.render("activitypub-explore", {
|
||||||
title: response.locals.__("activitypub.reader.explore.title"),
|
title: response.locals.__("activitypub.reader.explore.title"),
|
||||||
readerParent,
|
readerParent,
|
||||||
instance,
|
instance,
|
||||||
scope,
|
scope,
|
||||||
|
hashtag: hashtag || "",
|
||||||
items,
|
items,
|
||||||
maxId: nextMaxId,
|
maxId: nextMaxId,
|
||||||
error,
|
error,
|
||||||
mountPath,
|
mountPath,
|
||||||
activeTab,
|
|
||||||
decks,
|
|
||||||
deckCount,
|
|
||||||
isInDeck,
|
|
||||||
csrfToken,
|
csrfToken,
|
||||||
// Pass empty interactionMap — explore posts are not in our DB
|
// Pass empty interactionMap — explore posts are not in our DB
|
||||||
interactionMap: {},
|
interactionMap: {},
|
||||||
@@ -263,15 +140,24 @@ export function exploreApiController(mountPath) {
|
|||||||
const rawInstance = request.query.instance || "";
|
const rawInstance = request.query.instance || "";
|
||||||
const scope = request.query.scope === "federated" ? "federated" : "local";
|
const scope = request.query.scope === "federated" ? "federated" : "local";
|
||||||
const maxId = request.query.max_id || "";
|
const maxId = request.query.max_id || "";
|
||||||
|
const rawHashtag = request.query.hashtag || "";
|
||||||
|
const hashtag = rawHashtag ? validateHashtag(rawHashtag) : null;
|
||||||
|
|
||||||
const instance = validateInstance(rawInstance);
|
const instance = validateInstance(rawInstance);
|
||||||
if (!instance) {
|
if (!instance) {
|
||||||
return response.status(400).json({ error: "Invalid instance" });
|
return response.status(400).json({ error: "Invalid instance" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build API URL: hashtag timeline or public timeline
|
||||||
const isLocal = scope === "local";
|
const isLocal = scope === "local";
|
||||||
const apiUrl = new URL(`https://${instance}/api/v1/timelines/public`);
|
let apiUrl;
|
||||||
apiUrl.searchParams.set("local", isLocal ? "true" : "false");
|
if (hashtag) {
|
||||||
|
apiUrl = new URL(`https://${instance}/api/v1/timelines/tag/${encodeURIComponent(hashtag)}`);
|
||||||
|
apiUrl.searchParams.set("local", isLocal ? "true" : "false");
|
||||||
|
} else {
|
||||||
|
apiUrl = new URL(`https://${instance}/api/v1/timelines/public`);
|
||||||
|
apiUrl.searchParams.set("local", isLocal ? "true" : "false");
|
||||||
|
}
|
||||||
apiUrl.searchParams.set("limit", String(MAX_RESULTS));
|
apiUrl.searchParams.set("limit", String(MAX_RESULTS));
|
||||||
if (maxId) apiUrl.searchParams.set("max_id", maxId);
|
if (maxId) apiUrl.searchParams.set("max_id", maxId);
|
||||||
|
|
||||||
|
|||||||
223
lib/controllers/hashtag-explore.js
Normal file
223
lib/controllers/hashtag-explore.js
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
/**
|
||||||
|
* Hashtag explore API — aggregates a hashtag timeline across all pinned instance tabs.
|
||||||
|
*
|
||||||
|
* GET /admin/reader/api/explore/hashtag
|
||||||
|
* ?hashtag={tag}
|
||||||
|
* &cursors={json} — JSON-encoded { domain: maxId } cursor map for pagination
|
||||||
|
*
|
||||||
|
* Returns JSON:
|
||||||
|
* {
|
||||||
|
* html: string, — server-rendered HTML cards
|
||||||
|
* cursors: { [domain]: string|null }, — updated cursor map
|
||||||
|
* sources: { [domain]: "ok" | "error:N" },
|
||||||
|
* instancesQueried: number,
|
||||||
|
* instancesTotal: number,
|
||||||
|
* instanceLabels: string[],
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { validateHashtag, mapMastodonStatusToItem } from "./explore-utils.js";
|
||||||
|
|
||||||
|
const FETCH_TIMEOUT_MS = 10_000;
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
const MAX_HASHTAG_INSTANCES = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch hashtag timeline from one instance.
|
||||||
|
* Returns { statuses, nextMaxId, error }.
|
||||||
|
*/
|
||||||
|
async function fetchHashtagFromInstance(domain, scope, hashtag, maxId) {
|
||||||
|
try {
|
||||||
|
const isLocal = scope === "local";
|
||||||
|
const url = new URL(
|
||||||
|
`https://${domain}/api/v1/timelines/tag/${encodeURIComponent(hashtag)}`
|
||||||
|
);
|
||||||
|
url.searchParams.set("local", isLocal ? "true" : "false");
|
||||||
|
url.searchParams.set("limit", "20");
|
||||||
|
if (maxId) url.searchParams.set("max_id", maxId);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||||
|
|
||||||
|
const res = await fetch(url.toString(), {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return { statuses: [], nextMaxId: null, error: `error:${res.status}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const statuses = await res.json();
|
||||||
|
if (!Array.isArray(statuses)) {
|
||||||
|
return { statuses: [], nextMaxId: null, error: "error:invalid" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMaxId =
|
||||||
|
statuses.length === 20 && statuses.length > 0
|
||||||
|
? statuses[statuses.length - 1].id || null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return { statuses, nextMaxId, error: null };
|
||||||
|
} catch {
|
||||||
|
return { statuses: [], nextMaxId: null, error: "error:timeout" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hashtag explore API controller.
|
||||||
|
* Queries up to MAX_HASHTAG_INSTANCES pinned instance tabs in parallel.
|
||||||
|
*/
|
||||||
|
export function hashtagExploreApiController(mountPath) {
|
||||||
|
return async (request, response, next) => {
|
||||||
|
try {
|
||||||
|
// Validate hashtag
|
||||||
|
const rawHashtag = request.query.hashtag || "";
|
||||||
|
const hashtag = validateHashtag(rawHashtag);
|
||||||
|
if (!hashtag) {
|
||||||
|
return response.status(400).json({ error: "Invalid hashtag" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabsCollection = request.app.locals.application?.collections?.get("ap_explore_tabs");
|
||||||
|
if (!tabsCollection) {
|
||||||
|
return response.json({
|
||||||
|
html: "", cursors: {}, sources: {}, instancesQueried: 0, instancesTotal: 0, instanceLabels: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse cursors map — { [domain]: maxId | null }
|
||||||
|
let cursors = {};
|
||||||
|
try {
|
||||||
|
const raw = request.query.cursors || "{}";
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||||
|
cursors = parsed;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid JSON — use empty cursors (start from beginning)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load instance tabs, capped at MAX_HASHTAG_INSTANCES by order
|
||||||
|
const instanceTabs = await tabsCollection
|
||||||
|
.find({ type: "instance" })
|
||||||
|
.sort({ order: 1 })
|
||||||
|
.limit(MAX_HASHTAG_INSTANCES)
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
const instancesTotal = await tabsCollection.countDocuments({
|
||||||
|
type: "instance",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (instanceTabs.length === 0) {
|
||||||
|
return response.json({
|
||||||
|
html: "",
|
||||||
|
cursors: {},
|
||||||
|
sources: {},
|
||||||
|
instancesQueried: 0,
|
||||||
|
instancesTotal,
|
||||||
|
instanceLabels: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch from all instances in parallel
|
||||||
|
const fetchResults = await Promise.allSettled(
|
||||||
|
instanceTabs.map((tab) =>
|
||||||
|
fetchHashtagFromInstance(
|
||||||
|
tab.domain,
|
||||||
|
tab.scope,
|
||||||
|
hashtag,
|
||||||
|
cursors[tab.domain] || null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build sources map and collect all statuses with their domain
|
||||||
|
const sources = {};
|
||||||
|
const updatedCursors = {};
|
||||||
|
const allItems = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < instanceTabs.length; i++) {
|
||||||
|
const tab = instanceTabs[i];
|
||||||
|
const result = fetchResults[i];
|
||||||
|
|
||||||
|
if (result.status === "fulfilled") {
|
||||||
|
const { statuses, nextMaxId, error } = result.value;
|
||||||
|
sources[tab.domain] = error || "ok";
|
||||||
|
updatedCursors[tab.domain] = nextMaxId;
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
|
for (const status of statuses) {
|
||||||
|
allItems.push({ status, domain: tab.domain });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sources[tab.domain] = "error:rejected";
|
||||||
|
updatedCursors[tab.domain] = cursors[tab.domain] || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge by published date descending
|
||||||
|
allItems.sort((a, b) => {
|
||||||
|
const dateA = new Date(a.status.created_at || 0).getTime();
|
||||||
|
const dateB = new Date(b.status.created_at || 0).getTime();
|
||||||
|
return dateB - dateA;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deduplicate by post URL (first occurrence wins)
|
||||||
|
const seenUrls = new Set();
|
||||||
|
const dedupedItems = [];
|
||||||
|
for (const { status, domain } of allItems) {
|
||||||
|
const uid = status.url || status.uri || "";
|
||||||
|
if (uid && seenUrls.has(uid)) continue;
|
||||||
|
if (uid) seenUrls.add(uid);
|
||||||
|
dedupedItems.push({ status, domain });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paginate: take first PAGE_SIZE items
|
||||||
|
const pageItems = dedupedItems.slice(0, PAGE_SIZE);
|
||||||
|
|
||||||
|
// Map to timeline item format
|
||||||
|
const items = pageItems.map(({ status, domain }) =>
|
||||||
|
mapMastodonStatusToItem(status, domain)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render HTML AFTER merge/dedup/paginate (don't waste CPU on discarded items)
|
||||||
|
const templateData = {
|
||||||
|
...response.locals,
|
||||||
|
mountPath,
|
||||||
|
csrfToken: "",
|
||||||
|
interactionMap: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const htmlParts = await Promise.all(
|
||||||
|
items.map(
|
||||||
|
(item) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
request.app.render(
|
||||||
|
"partials/ap-item-card.njk",
|
||||||
|
{ ...templateData, item },
|
||||||
|
(err, html) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(html);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const instanceLabels = instanceTabs.map((t) => t.domain);
|
||||||
|
|
||||||
|
response.json({
|
||||||
|
html: htmlParts.join(""),
|
||||||
|
cursors: updatedCursors,
|
||||||
|
sources,
|
||||||
|
instancesQueried: instanceTabs.length,
|
||||||
|
instancesTotal,
|
||||||
|
instanceLabels,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
245
lib/controllers/tabs.js
Normal file
245
lib/controllers/tabs.js
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
/**
|
||||||
|
* Tab CRUD controller — manages explore tab entries.
|
||||||
|
* Stored in the ap_explore_tabs MongoDB collection.
|
||||||
|
*
|
||||||
|
* Tab types:
|
||||||
|
* - "instance": pinned Mastodon-compatible instance with scope (local/federated)
|
||||||
|
* - "hashtag": aggregated hashtag across all pinned instance tabs
|
||||||
|
*
|
||||||
|
* IMPORTANT: All insertions must explicitly set all four indexed fields.
|
||||||
|
* Missing fields and null are treated differently by MongoDB compound unique indexes.
|
||||||
|
* Instance tabs: { type, domain, scope, hashtag: null, order, addedAt }
|
||||||
|
* Hashtag tabs: { type, domain: null, scope: null, hashtag, order, addedAt }
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ObjectId } from "mongodb";
|
||||||
|
import { validateToken } from "../csrf.js";
|
||||||
|
import { validateInstance, validateHashtag } from "./explore-utils.js";
|
||||||
|
|
||||||
|
// Re-export for consumers that imported from tabs.js
|
||||||
|
export { validateHashtag };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /admin/reader/api/tabs
|
||||||
|
* Returns all tab entries sorted by order ascending.
|
||||||
|
*/
|
||||||
|
export function listTabsController(_mountPath) {
|
||||||
|
return async (request, response, next) => {
|
||||||
|
try {
|
||||||
|
const { application } = request.app.locals;
|
||||||
|
const collection = application?.collections?.get("ap_explore_tabs");
|
||||||
|
if (!collection) {
|
||||||
|
return response.json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs = await collection
|
||||||
|
.find({}, { projection: { _id: 1, type: 1, domain: 1, scope: 1, hashtag: 1, order: 1, addedAt: 1 } })
|
||||||
|
.sort({ order: 1 })
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
return response.json(tabs);
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /admin/reader/api/tabs
|
||||||
|
* Adds a new tab entry.
|
||||||
|
* Body (instance tab): { type: "instance", domain, scope }
|
||||||
|
* Body (hashtag tab): { type: "hashtag", hashtag }
|
||||||
|
*/
|
||||||
|
export function addTabController(_mountPath) {
|
||||||
|
return async (request, response, next) => {
|
||||||
|
try {
|
||||||
|
// CSRF protection
|
||||||
|
if (!validateToken(request)) {
|
||||||
|
return response.status(403).json({ error: "Invalid CSRF token" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { application } = request.app.locals;
|
||||||
|
const collection = application?.collections?.get("ap_explore_tabs");
|
||||||
|
if (!collection) {
|
||||||
|
return response.status(500).json({ error: "Tab storage unavailable" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { type } = request.body;
|
||||||
|
|
||||||
|
if (type !== "instance" && type !== "hashtag") {
|
||||||
|
return response.status(400).json({ error: "Invalid tab type" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine the next order value atomically
|
||||||
|
const lastTab = await collection
|
||||||
|
.find({})
|
||||||
|
.sort({ order: -1 })
|
||||||
|
.limit(1)
|
||||||
|
.toArray();
|
||||||
|
const nextOrder = lastTab.length > 0 ? lastTab[0].order + 1 : 0;
|
||||||
|
|
||||||
|
let tab;
|
||||||
|
|
||||||
|
if (type === "instance") {
|
||||||
|
const { domain: rawDomain, scope: rawScope } = request.body;
|
||||||
|
|
||||||
|
// Validate domain (SSRF prevention)
|
||||||
|
const domain = validateInstance(rawDomain);
|
||||||
|
if (!domain) {
|
||||||
|
return response.status(400).json({ error: "Invalid instance domain" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate scope
|
||||||
|
const scope = rawScope === "federated" ? "federated" : "local";
|
||||||
|
|
||||||
|
// All four indexed fields must be explicitly set
|
||||||
|
tab = {
|
||||||
|
type: "instance",
|
||||||
|
domain,
|
||||||
|
scope,
|
||||||
|
hashtag: null, // explicit null — required for unique index
|
||||||
|
order: nextOrder,
|
||||||
|
addedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// type === "hashtag"
|
||||||
|
const { hashtag: rawHashtag } = request.body;
|
||||||
|
|
||||||
|
const hashtag = validateHashtag(rawHashtag);
|
||||||
|
if (!hashtag) {
|
||||||
|
return response.status(400).json({
|
||||||
|
error:
|
||||||
|
"Invalid hashtag. Use alphanumeric characters and underscores only (max 100 chars).",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// All four indexed fields must be explicitly set
|
||||||
|
tab = {
|
||||||
|
type: "hashtag",
|
||||||
|
domain: null, // explicit null — required for unique index
|
||||||
|
scope: null, // explicit null — required for unique index
|
||||||
|
hashtag,
|
||||||
|
order: nextOrder,
|
||||||
|
addedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await collection.insertOne(tab);
|
||||||
|
// Return with the MongoDB _id included
|
||||||
|
return response.status(201).json({ ...tab, _id: result.insertedId });
|
||||||
|
} catch (insertError) {
|
||||||
|
if (insertError.code === 11_000) {
|
||||||
|
return response.status(409).json({ error: "Tab already exists" });
|
||||||
|
}
|
||||||
|
throw insertError;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /admin/reader/api/tabs/remove
|
||||||
|
* Removes a tab entry and re-compacts order numbers.
|
||||||
|
* Body (instance tab): { type: "instance", domain, scope }
|
||||||
|
* Body (hashtag tab): { type: "hashtag", hashtag }
|
||||||
|
*/
|
||||||
|
export function removeTabController(_mountPath) {
|
||||||
|
return async (request, response, next) => {
|
||||||
|
try {
|
||||||
|
// CSRF protection
|
||||||
|
if (!validateToken(request)) {
|
||||||
|
return response.status(403).json({ error: "Invalid CSRF token" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { application } = request.app.locals;
|
||||||
|
const collection = application?.collections?.get("ap_explore_tabs");
|
||||||
|
if (!collection) {
|
||||||
|
return response.status(500).json({ error: "Tab storage unavailable" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { type } = request.body;
|
||||||
|
let filter;
|
||||||
|
|
||||||
|
if (type === "instance") {
|
||||||
|
const domain = validateInstance(request.body.domain);
|
||||||
|
if (!domain) {
|
||||||
|
return response.status(400).json({ error: "Invalid instance domain" });
|
||||||
|
}
|
||||||
|
const scope = request.body.scope === "federated" ? "federated" : "local";
|
||||||
|
filter = { type: "instance", domain, scope };
|
||||||
|
} else if (type === "hashtag") {
|
||||||
|
const hashtag = validateHashtag(request.body.hashtag);
|
||||||
|
if (!hashtag) {
|
||||||
|
return response.status(400).json({ error: "Invalid hashtag" });
|
||||||
|
}
|
||||||
|
filter = { type: "hashtag", hashtag };
|
||||||
|
} else {
|
||||||
|
return response.status(400).json({ error: "Invalid tab type" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await collection.deleteOne(filter);
|
||||||
|
|
||||||
|
// Re-compact order numbers to avoid gaps
|
||||||
|
const remaining = await collection.find({}).sort({ order: 1 }).toArray();
|
||||||
|
await Promise.all(
|
||||||
|
remaining.map((tab, index) =>
|
||||||
|
collection.updateOne({ _id: tab._id }, { $set: { order: index } }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /admin/reader/api/tabs/reorder
|
||||||
|
* Reorders tabs by accepting an array of tab IDs in the desired order.
|
||||||
|
* Body: { tabIds: ["<mongoId1>", "<mongoId2>", ...] }
|
||||||
|
* Sets order = index for each tab ID.
|
||||||
|
*/
|
||||||
|
export function reorderTabsController(_mountPath) {
|
||||||
|
return async (request, response, next) => {
|
||||||
|
try {
|
||||||
|
// CSRF protection
|
||||||
|
if (!validateToken(request)) {
|
||||||
|
return response.status(403).json({ error: "Invalid CSRF token" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { application } = request.app.locals;
|
||||||
|
const collection = application?.collections?.get("ap_explore_tabs");
|
||||||
|
if (!collection) {
|
||||||
|
return response.status(500).json({ error: "Tab storage unavailable" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { tabIds } = request.body;
|
||||||
|
if (!Array.isArray(tabIds) || tabIds.length > 100) {
|
||||||
|
return response.status(400).json({ error: "tabIds must be an array (max 100)" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate each ID is a valid ObjectId hex string
|
||||||
|
const objectIdPattern = /^[a-f\d]{24}$/;
|
||||||
|
if (tabIds.some((id) => typeof id !== "string" || !objectIdPattern.test(id))) {
|
||||||
|
return response.status(400).json({ error: "Invalid tab ID format" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
tabIds.map((id, index) =>
|
||||||
|
collection.updateOne(
|
||||||
|
{ _id: new ObjectId(id) },
|
||||||
|
{ $set: { order: index } },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -240,22 +240,25 @@
|
|||||||
"mauLabel": "MAU",
|
"mauLabel": "MAU",
|
||||||
"timelineSupported": "Public timeline available",
|
"timelineSupported": "Public timeline available",
|
||||||
"timelineUnsupported": "Public timeline not available",
|
"timelineUnsupported": "Public timeline not available",
|
||||||
|
"hashtagLabel": "Hashtag (optional)",
|
||||||
|
"hashtagPlaceholder": "e.g. indieweb",
|
||||||
|
"hashtagHint": "Filter results to a specific hashtag",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
|
"label": "Explore tabs",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
"decks": "Decks"
|
"pinAsTab": "Pin as tab",
|
||||||
},
|
"pinned": "Pinned",
|
||||||
"deck": {
|
"remove": "Remove tab",
|
||||||
"addToDeck": "Add to deck",
|
"moveUp": "Move up",
|
||||||
"removeFromDeck": "Remove from deck",
|
"moveDown": "Move down",
|
||||||
"inDeck": "In deck",
|
"addHashtag": "Add hashtag tab",
|
||||||
"deckLimitReached": "Maximum of 8 decks reached",
|
"hashtagTabPlaceholder": "Enter hashtag",
|
||||||
"localBadge": "Local",
|
"addTab": "Add",
|
||||||
"federatedBadge": "Federated",
|
|
||||||
"removeColumn": "Remove column",
|
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"loadError": "Could not load timeline from this instance.",
|
"noInstances": "Pin some instances first to use hashtag tabs.",
|
||||||
"emptyState": "No decks yet. Browse an instance in the Search tab and click the star to add it.",
|
"sources": "Searching #%s across %d instance",
|
||||||
"emptyStateLink": "Go to Search"
|
"sources_plural": "Searching #%s across %d instances",
|
||||||
|
"sourcesPartial": "%d of %d instances responded"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tagTimeline": {
|
"tagTimeline": {
|
||||||
|
|||||||
@@ -9,210 +9,381 @@
|
|||||||
<p class="ap-explore-header__desc">{{ __("activitypub.reader.explore.description") }}</p>
|
<p class="ap-explore-header__desc">{{ __("activitypub.reader.explore.description") }}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{# Tab navigation #}
|
{# ── Tabbed explore container (Alpine.js component) ──────────────────── #}
|
||||||
{% set exploreBase = mountPath + "/admin/reader/explore" %}
|
<div class="ap-explore-tabs-container"
|
||||||
<nav class="ap-tabs">
|
data-mount-path="{{ mountPath }}"
|
||||||
<a href="{{ exploreBase }}" class="ap-tab{% if activeTab != 'decks' %} ap-tab--active{% endif %}">
|
data-csrf="{{ csrfToken }}"
|
||||||
{{ __("activitypub.reader.explore.tabs.search") }}
|
x-data="apExploreTabs()">
|
||||||
</a>
|
|
||||||
<a href="{{ exploreBase }}?tab=decks" class="ap-tab{% if activeTab == 'decks' %} ap-tab--active{% endif %}">
|
|
||||||
{{ __("activitypub.reader.explore.tabs.decks") }}
|
|
||||||
{% if decks and decks.length > 0 %}
|
|
||||||
<span class="ap-tab__count">{{ decks.length }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{# ── Search tab ────────────────────────────────────────────────── #}
|
{# ── Tab bar ──────────────────────────────────────────────────────────── #}
|
||||||
{% if activeTab != 'decks' %}
|
<nav class="ap-tabs ap-explore-tabs-nav"
|
||||||
|
id="ap-explore-tab-bar"
|
||||||
|
role="tablist"
|
||||||
|
aria-label="{{ __('activitypub.reader.explore.tabs.label') }}">
|
||||||
|
|
||||||
{# Instance form with autocomplete #}
|
{# Search tab — always first, not removable #}
|
||||||
<form action="{{ mountPath }}/admin/reader/explore" method="get" class="ap-explore-form"
|
<button
|
||||||
x-data="apInstanceSearch('{{ mountPath }}')"
|
type="button"
|
||||||
@submit="onSubmit">
|
class="ap-tab"
|
||||||
<div class="ap-explore-form__row">
|
:class="{ 'ap-tab--active': activeTabId === null }"
|
||||||
<div class="ap-explore-autocomplete">
|
role="tab"
|
||||||
<input
|
:aria-selected="activeTabId === null ? 'true' : 'false'"
|
||||||
type="text"
|
aria-controls="ap-tab-panel-search"
|
||||||
name="instance"
|
id="ap-tab-btn-search"
|
||||||
value="{{ instance }}"
|
@click="switchToSearch()"
|
||||||
class="ap-explore-form__input"
|
@keydown="handleTabKeydown($event, 0)">
|
||||||
placeholder="{{ __('activitypub.reader.explore.instancePlaceholder') }}"
|
{{ __("activitypub.reader.explore.tabs.search") }}
|
||||||
aria-label="{{ __('activitypub.reader.explore.instancePlaceholder') }}"
|
|
||||||
autocomplete="off"
|
|
||||||
required
|
|
||||||
x-model="query"
|
|
||||||
@input.debounce.300ms="search()"
|
|
||||||
@keydown.arrow-down.prevent="highlightNext()"
|
|
||||||
@keydown.arrow-up.prevent="highlightPrev()"
|
|
||||||
@keydown.enter="selectHighlighted($event)"
|
|
||||||
@keydown.escape="close()"
|
|
||||||
@focus="showResults && suggestions.length > 0 ? showResults = true : null"
|
|
||||||
@click.away="close()"
|
|
||||||
x-ref="input">
|
|
||||||
|
|
||||||
{# Autocomplete dropdown #}
|
|
||||||
<div class="ap-explore-autocomplete__dropdown" x-show="showResults && suggestions.length > 0" x-cloak>
|
|
||||||
<template x-for="(item, index) in suggestions" :key="item.domain">
|
|
||||||
<button type="button"
|
|
||||||
class="ap-explore-autocomplete__item"
|
|
||||||
:class="{ 'ap-explore-autocomplete__item--highlighted': index === highlighted }"
|
|
||||||
@click="selectItem(item)"
|
|
||||||
@mouseenter="highlighted = index">
|
|
||||||
<span class="ap-explore-autocomplete__domain" x-text="item.domain"></span>
|
|
||||||
<span class="ap-explore-autocomplete__meta">
|
|
||||||
<span class="ap-explore-autocomplete__software" x-text="item.software"></span>
|
|
||||||
<template x-if="item.mau > 0">
|
|
||||||
<span class="ap-explore-autocomplete__mau" x-text="item.mau.toLocaleString() + ' {{ __("activitypub.reader.explore.mauLabel") }}'"></span>
|
|
||||||
</template>
|
|
||||||
</span>
|
|
||||||
<span class="ap-explore-autocomplete__status" x-show="item._timelineStatus !== undefined">
|
|
||||||
<template x-if="item._timelineStatus === 'checking'">
|
|
||||||
<span class="ap-explore-autocomplete__checking">⏳</span>
|
|
||||||
</template>
|
|
||||||
<template x-if="item._timelineStatus === true">
|
|
||||||
<span class="ap-explore-autocomplete__supported" title="{{ __('activitypub.reader.explore.timelineSupported') }}">✅</span>
|
|
||||||
</template>
|
|
||||||
<template x-if="item._timelineStatus === false">
|
|
||||||
<span class="ap-explore-autocomplete__unsupported" title="{{ __('activitypub.reader.explore.timelineUnsupported') }}">❌</span>
|
|
||||||
</template>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="ap-explore-form__scope">
|
|
||||||
<label class="ap-explore-form__scope-label">
|
|
||||||
<input type="radio" name="scope" value="local"
|
|
||||||
{% if scope == "local" %}checked{% endif %}>
|
|
||||||
{{ __("activitypub.reader.explore.local") }}
|
|
||||||
</label>
|
|
||||||
<label class="ap-explore-form__scope-label">
|
|
||||||
<input type="radio" name="scope" value="federated"
|
|
||||||
{% if scope == "federated" %}checked{% endif %}>
|
|
||||||
{{ __("activitypub.reader.explore.federated") }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="ap-explore-form__btn">
|
|
||||||
{{ __("activitypub.reader.explore.browse") }}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{# Error state #}
|
{# User-created instance and hashtag tabs #}
|
||||||
{% if error %}
|
<template x-for="(tab, index) in tabs" :key="tab._id">
|
||||||
<div class="ap-explore-error">{{ error }}</div>
|
<div class="ap-tab-wrapper">
|
||||||
{% endif %}
|
{# Tab button #}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ap-tab ap-tab--user"
|
||||||
|
:class="{ 'ap-tab--active': activeTabId === tab._id }"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="activeTabId === tab._id ? 'true' : 'false'"
|
||||||
|
:aria-controls="'ap-tab-panel-' + tab._id"
|
||||||
|
:id="'ap-tab-btn-' + tab._id"
|
||||||
|
@click="switchTab(tab._id)"
|
||||||
|
@keydown="handleTabKeydown($event, index + 1)">
|
||||||
|
<span class="ap-tab__label" :title="tabLabel(tab)" x-text="tabLabel(tab)"></span>
|
||||||
|
<span
|
||||||
|
x-show="tab.type === 'instance'"
|
||||||
|
class="ap-tab__badge"
|
||||||
|
:class="tab.scope === 'local' ? 'ap-tab__badge--local' : 'ap-tab__badge--federated'"
|
||||||
|
x-text="tab.scope"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
{# Results #}
|
{# Reorder and close controls #}
|
||||||
{% if instance and not error %}
|
<div class="ap-tab-controls">
|
||||||
{# Add to deck toggle button (shown when browsing results) #}
|
<button
|
||||||
{% if items.length > 0 %}
|
type="button"
|
||||||
<div class="ap-explore-deck-toggle"
|
class="ap-tab-control ap-tab-control--up"
|
||||||
x-data="apDeckToggle('{{ instance }}', '{{ scope }}', '{{ mountPath }}', '{{ csrfToken }}', {{ deckCount }}, {{ 'true' if isInDeck else 'false' }})">
|
@click.stop="moveUp(tab)"
|
||||||
|
:disabled="index === 0"
|
||||||
|
:aria-label="'{{ __('activitypub.reader.explore.tabs.moveUp') }}: ' + tabLabel(tab)">↑</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ap-tab-control ap-tab-control--down"
|
||||||
|
@click.stop="moveDown(tab)"
|
||||||
|
:disabled="index === tabs.length - 1"
|
||||||
|
:aria-label="'{{ __('activitypub.reader.explore.tabs.moveDown') }}: ' + tabLabel(tab)">↓</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ap-tab-control ap-tab-control--remove"
|
||||||
|
@click.stop="removeTab(tab)"
|
||||||
|
:aria-label="'{{ __('activitypub.reader.explore.tabs.remove') }}: ' + tabLabel(tab)">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
{# +# button — add a hashtag tab #}
|
||||||
|
<div class="ap-tab-add-hashtag">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="ap-explore-deck-toggle__btn"
|
class="ap-tab ap-tab--add"
|
||||||
:class="{ 'ap-explore-deck-toggle__btn--active': inDeck }"
|
@click="showHashtagForm = !showHashtagForm"
|
||||||
@click="toggle()"
|
:aria-expanded="showHashtagForm ? 'true' : 'false'"
|
||||||
:disabled="!inDeck && deckLimitReached"
|
title="{{ __('activitypub.reader.explore.tabs.addHashtag') }}">+#</button>
|
||||||
:title="!inDeck && deckLimitReached ? '{{ __('activitypub.reader.explore.deck.deckLimitReached') }}' : (inDeck ? '{{ __('activitypub.reader.explore.deck.removeFromDeck') }}' : '{{ __('activitypub.reader.explore.deck.addToDeck') }}')"
|
<form
|
||||||
:aria-label="!inDeck && deckLimitReached ? '{{ __('activitypub.reader.explore.deck.deckLimitReached') }}' : (inDeck ? '{{ __('activitypub.reader.explore.deck.removeFromDeck') }}' : '{{ __('activitypub.reader.explore.deck.addToDeck') }}')"
|
x-show="showHashtagForm"
|
||||||
x-text="inDeck ? '★ {{ __('activitypub.reader.explore.deck.inDeck') }}' : '☆ {{ __('activitypub.reader.explore.deck.addToDeck') }}'">
|
x-cloak
|
||||||
</button>
|
class="ap-tab-hashtag-form"
|
||||||
</div>
|
@submit.prevent="submitHashtagTab()"
|
||||||
{% endif %}
|
@keydown.escape.prevent="showHashtagForm = false; hashtagInput = ''">
|
||||||
|
<span class="ap-tab-hashtag-form__prefix">#</span>
|
||||||
{% if items.length > 0 %}
|
<input
|
||||||
<div class="ap-timeline ap-explore-timeline"
|
type="text"
|
||||||
id="ap-explore-timeline"
|
x-model="hashtagInput"
|
||||||
data-instance="{{ instance }}"
|
class="ap-tab-hashtag-form__input"
|
||||||
data-scope="{{ scope }}"
|
placeholder="{{ __('activitypub.reader.explore.tabs.hashtagTabPlaceholder') }}"
|
||||||
data-mount-path="{{ mountPath }}"
|
aria-label="{{ __('activitypub.reader.explore.tabs.addHashtag') }}"
|
||||||
data-max-id="{{ maxId if maxId else '' }}">
|
autocomplete="off"
|
||||||
{% for item in items %}
|
maxlength="100">
|
||||||
{% include "partials/ap-item-card.njk" %}
|
<button type="submit" class="ap-tab-hashtag-form__btn">
|
||||||
{% endfor %}
|
{{ __("activitypub.reader.explore.tabs.addTab") }}
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Infinite scroll for explore page #}
|
|
||||||
{% if maxId %}
|
|
||||||
<div class="ap-load-more"
|
|
||||||
id="ap-explore-load-more"
|
|
||||||
data-max-id="{{ maxId }}"
|
|
||||||
data-instance="{{ instance }}"
|
|
||||||
data-scope="{{ scope }}"
|
|
||||||
x-data="apExploreScroll()"
|
|
||||||
x-init="init()">
|
|
||||||
<div class="ap-load-more__sentinel" x-ref="sentinel"></div>
|
|
||||||
<button class="ap-load-more__btn" @click="loadMore()" :disabled="loading" x-show="!done">
|
|
||||||
<span x-show="!loading">{{ __("activitypub.reader.pagination.loadMore") }}</span>
|
|
||||||
<span x-show="loading">{{ __("activitypub.reader.pagination.loading") }}</span>
|
|
||||||
</button>
|
</button>
|
||||||
<p class="ap-load-more__done" x-show="done" x-cloak>{{ __("activitypub.reader.pagination.noMore") }}</p>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
</nav>{# end tab bar nav #}
|
||||||
{% elif instance %}
|
|
||||||
{{ prose({ text: __("activitypub.reader.explore.noResults") }) }}
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% endif %}{# end Search tab #}
|
{# Error message (CSRF expiry, network errors) #}
|
||||||
|
<p x-show="error" x-cloak class="ap-explore-error" x-text="error"></p>
|
||||||
|
|
||||||
{# ── Decks tab ──────────────────────────────────────────────────── #}
|
{# ── Search tab panel ─────────────────────────────────────────────────── #}
|
||||||
{% if activeTab == 'decks' %}
|
<div id="ap-tab-panel-search"
|
||||||
{% if decks and decks.length > 0 %}
|
role="tabpanel"
|
||||||
<div class="ap-deck-grid" data-csrf-token="{{ csrfToken }}">
|
aria-labelledby="ap-tab-btn-search"
|
||||||
{% for deck in decks %}
|
x-show="activeTabId === null">
|
||||||
<div class="ap-deck-column"
|
|
||||||
x-data="apDeckColumn('{{ deck.domain }}', '{{ deck.scope }}', '{{ mountPath }}', {{ loop.index0 }}, '{{ csrfToken }}')"
|
{# Instance form with autocomplete #}
|
||||||
x-init="init()">
|
<form action="{{ mountPath }}/admin/reader/explore" method="get" class="ap-explore-form"
|
||||||
<header class="ap-deck-column__header">
|
x-data="apInstanceSearch('{{ mountPath }}')"
|
||||||
<span class="ap-deck-column__domain">{{ deck.domain }}</span>
|
@submit="onSubmit">
|
||||||
<span class="ap-deck-column__scope-badge ap-deck-column__scope-badge--{{ deck.scope }}">
|
<div class="ap-explore-form__row">
|
||||||
{{ __("activitypub.reader.explore.deck." + deck.scope + "Badge") }}
|
<div class="ap-explore-autocomplete">
|
||||||
</span>
|
<input
|
||||||
<button
|
type="text"
|
||||||
type="button"
|
name="instance"
|
||||||
class="ap-deck-column__remove"
|
value="{{ instance }}"
|
||||||
@click="removeDeck()"
|
class="ap-explore-form__input"
|
||||||
title="{{ __('activitypub.reader.explore.deck.removeColumn') }}"
|
placeholder="{{ __('activitypub.reader.explore.instancePlaceholder') }}"
|
||||||
aria-label="{{ __('activitypub.reader.explore.deck.removeColumn') }}">×</button>
|
aria-label="{{ __('activitypub.reader.explore.instancePlaceholder') }}"
|
||||||
</header>
|
autocomplete="off"
|
||||||
<div class="ap-deck-column__body" x-ref="body">
|
required
|
||||||
<div x-show="loading && itemCount === 0" class="ap-deck-column__loading">
|
x-model="query"
|
||||||
<span>{{ __("activitypub.reader.pagination.loading") }}</span>
|
@input.debounce.300ms="search()"
|
||||||
</div>
|
@keydown.arrow-down.prevent="highlightNext()"
|
||||||
<div x-show="error" class="ap-deck-column__error" x-cloak>
|
@keydown.arrow-up.prevent="highlightPrev()"
|
||||||
<p x-text="error"></p>
|
@keydown.enter="selectHighlighted($event)"
|
||||||
<button type="button" class="ap-deck-column__retry" @click="retryLoad()">
|
@keydown.escape="close()"
|
||||||
{{ __("activitypub.reader.explore.deck.retry") }}
|
@focus="showResults && suggestions.length > 0 ? showResults = true : null"
|
||||||
|
@click.away="close()"
|
||||||
|
x-ref="input">
|
||||||
|
|
||||||
|
{# Autocomplete dropdown #}
|
||||||
|
<div class="ap-explore-autocomplete__dropdown" x-show="showResults && suggestions.length > 0" x-cloak>
|
||||||
|
<template x-for="(item, index) in suggestions" :key="item.domain">
|
||||||
|
<button type="button"
|
||||||
|
class="ap-explore-autocomplete__item"
|
||||||
|
:class="{ 'ap-explore-autocomplete__item--highlighted': index === highlighted }"
|
||||||
|
@click="selectItem(item)"
|
||||||
|
@mouseenter="highlighted = index">
|
||||||
|
<span class="ap-explore-autocomplete__domain" x-text="item.domain"></span>
|
||||||
|
<span class="ap-explore-autocomplete__meta">
|
||||||
|
<span class="ap-explore-autocomplete__software" x-text="item.software"></span>
|
||||||
|
<template x-if="item.mau > 0">
|
||||||
|
<span class="ap-explore-autocomplete__mau" x-text="item.mau.toLocaleString() + ' {{ __("activitypub.reader.explore.mauLabel") }}'"></span>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
<span class="ap-explore-autocomplete__status" x-show="item._timelineStatus !== undefined">
|
||||||
|
<template x-if="item._timelineStatus === 'checking'">
|
||||||
|
<span class="ap-explore-autocomplete__checking">⏳</span>
|
||||||
|
</template>
|
||||||
|
<template x-if="item._timelineStatus === true">
|
||||||
|
<span class="ap-explore-autocomplete__supported" title="{{ __('activitypub.reader.explore.timelineSupported') }}">✅</span>
|
||||||
|
</template>
|
||||||
|
<template x-if="item._timelineStatus === false">
|
||||||
|
<span class="ap-explore-autocomplete__unsupported" title="{{ __('activitypub.reader.explore.timelineUnsupported') }}">❌</span>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</template>
|
||||||
<div x-show="!loading && !error && itemCount === 0" class="ap-deck-column__empty" x-cloak>
|
|
||||||
{{ __("activitypub.reader.explore.noResults") }}
|
|
||||||
</div>
|
|
||||||
<div x-html="html" class="ap-deck-column__items"></div>
|
|
||||||
<div class="ap-deck-column__sentinel" x-ref="sentinel"></div>
|
|
||||||
<div x-show="loading && itemCount > 0" class="ap-deck-column__loading-more" x-cloak>
|
|
||||||
<span>{{ __("activitypub.reader.pagination.loading") }}</span>
|
|
||||||
</div>
|
|
||||||
<p x-show="done && itemCount > 0" class="ap-deck-column__done" x-cloak>
|
|
||||||
{{ __("activitypub.reader.pagination.noMore") }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
|
||||||
|
<div class="ap-explore-form__scope">
|
||||||
|
<label class="ap-explore-form__scope-label">
|
||||||
|
<input type="radio" name="scope" value="local"
|
||||||
|
{% if scope == "local" %}checked{% endif %}>
|
||||||
|
{{ __("activitypub.reader.explore.local") }}
|
||||||
|
</label>
|
||||||
|
<label class="ap-explore-form__scope-label">
|
||||||
|
<input type="radio" name="scope" value="federated"
|
||||||
|
{% if scope == "federated" %}checked{% endif %}>
|
||||||
|
{{ __("activitypub.reader.explore.federated") }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="ap-explore-form__btn">
|
||||||
|
{{ __("activitypub.reader.explore.browse") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="ap-explore-form__hashtag-row">
|
||||||
|
<label class="ap-explore-form__hashtag-label" for="ap-explore-hashtag">
|
||||||
|
{{ __("activitypub.reader.explore.hashtagLabel") }}
|
||||||
|
</label>
|
||||||
|
<span class="ap-explore-form__hashtag-prefix">#</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="ap-explore-hashtag"
|
||||||
|
name="hashtag"
|
||||||
|
value="{{ hashtag }}"
|
||||||
|
class="ap-explore-form__input ap-explore-form__input--hashtag"
|
||||||
|
placeholder="{{ __('activitypub.reader.explore.hashtagPlaceholder') }}"
|
||||||
|
aria-label="{{ __('activitypub.reader.explore.hashtagPlaceholder') }}"
|
||||||
|
autocomplete="off"
|
||||||
|
pattern="[\w]+"
|
||||||
|
maxlength="100">
|
||||||
|
<span class="ap-explore-form__hashtag-hint">{{ __("activitypub.reader.explore.hashtagHint") }}</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{# Error state #}
|
||||||
|
{% if error %}
|
||||||
|
<div class="ap-explore-error">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Results #}
|
||||||
|
{% if instance and not error %}
|
||||||
|
{# Pin as tab button — outside form, inside apExploreTabs scope #}
|
||||||
|
<div class="ap-explore-pin-bar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ap-explore-pin-btn"
|
||||||
|
@click="pinInstance('{{ instance }}', '{{ scope }}')"
|
||||||
|
:disabled="pinning">
|
||||||
|
<span x-show="!pinning">{{ __("activitypub.reader.explore.tabs.pinAsTab") }}</span>
|
||||||
|
<span x-show="pinning" x-cloak>…</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if items.length > 0 %}
|
||||||
|
<div class="ap-timeline ap-explore-timeline"
|
||||||
|
id="ap-explore-timeline"
|
||||||
|
data-instance="{{ instance }}"
|
||||||
|
data-scope="{{ scope }}"
|
||||||
|
data-hashtag="{{ hashtag }}"
|
||||||
|
data-mount-path="{{ mountPath }}"
|
||||||
|
data-max-id="{{ maxId if maxId else '' }}">
|
||||||
|
{% for item in items %}
|
||||||
|
{% include "partials/ap-item-card.njk" %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Infinite scroll for explore search tab #}
|
||||||
|
{% if maxId %}
|
||||||
|
<div class="ap-load-more"
|
||||||
|
id="ap-explore-load-more"
|
||||||
|
data-max-id="{{ maxId }}"
|
||||||
|
data-instance="{{ instance }}"
|
||||||
|
data-scope="{{ scope }}"
|
||||||
|
data-hashtag="{{ hashtag }}"
|
||||||
|
x-data="apExploreScroll()"
|
||||||
|
x-init="init()">
|
||||||
|
<div class="ap-load-more__sentinel" x-ref="sentinel"></div>
|
||||||
|
<button class="ap-load-more__btn" @click="loadMore()" :disabled="loading" x-show="!done">
|
||||||
|
<span x-show="!loading">{{ __("activitypub.reader.pagination.loadMore") }}</span>
|
||||||
|
<span x-show="loading">{{ __("activitypub.reader.pagination.loading") }}</span>
|
||||||
|
</button>
|
||||||
|
<p class="ap-load-more__done" x-show="done" x-cloak>{{ __("activitypub.reader.pagination.noMore") }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% elif instance %}
|
||||||
|
{{ prose({ text: __("activitypub.reader.explore.noResults") }) }}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>{# end Search tab panel #}
|
||||||
|
|
||||||
|
{# ── Dynamic tab panels (instance + hashtag) ──────────────────────────── #}
|
||||||
|
<template x-for="tab in tabs" :key="tab._id + '-panel'">
|
||||||
|
<div
|
||||||
|
:id="'ap-tab-panel-' + tab._id"
|
||||||
|
role="tabpanel"
|
||||||
|
:aria-labelledby="'ap-tab-btn-' + tab._id"
|
||||||
|
x-show="activeTabId === tab._id"
|
||||||
|
x-cloak>
|
||||||
|
|
||||||
|
{# ── Instance tab panel ───────────────────────────────────────────── #}
|
||||||
|
<template x-if="tab.type === 'instance'">
|
||||||
|
<div class="ap-explore-instance-panel">
|
||||||
|
|
||||||
|
{# Loading spinner — first load, no content yet #}
|
||||||
|
<div class="ap-explore-tab-loading"
|
||||||
|
x-show="tabState[tab._id] && tabState[tab._id].loading && !tabState[tab._id].html">
|
||||||
|
<span class="ap-explore-tab-loading__text">{{ __("activitypub.reader.pagination.loading") }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Error state with retry #}
|
||||||
|
<div class="ap-explore-tab-error"
|
||||||
|
x-show="tabState[tab._id] && tabState[tab._id].error && !tabState[tab._id].html">
|
||||||
|
<p class="ap-explore-tab-error__message" x-text="tabState[tab._id] && tabState[tab._id].error"></p>
|
||||||
|
<button type="button" class="ap-explore-tab-error__retry" @click="retryTab(tab)">
|
||||||
|
{{ __("activitypub.reader.explore.tabs.retry") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Timeline content — server-rendered cards injected via x-html #}
|
||||||
|
<div class="ap-timeline ap-explore-tab-timeline"
|
||||||
|
x-show="tabState[tab._id] && tabState[tab._id].html"
|
||||||
|
x-html="tabState[tab._id] ? tabState[tab._id].html : ''">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Inline loading spinner for subsequent pages #}
|
||||||
|
<div class="ap-explore-tab-loading ap-explore-tab-loading--more"
|
||||||
|
x-show="tabState[tab._id] && tabState[tab._id].loading && tabState[tab._id].html">
|
||||||
|
<span class="ap-explore-tab-loading__text">{{ __("activitypub.reader.pagination.loading") }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Empty state — loaded successfully but no posts #}
|
||||||
|
<div class="ap-explore-tab-empty"
|
||||||
|
x-show="tabState[tab._id] && tabState[tab._id].done && !tabState[tab._id].html && !tabState[tab._id].loading && !tabState[tab._id].error">
|
||||||
|
<p>{{ __("activitypub.reader.explore.noResults") }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# End of feed message #}
|
||||||
|
<p class="ap-load-more__done"
|
||||||
|
x-show="tabState[tab._id] && tabState[tab._id].done && tabState[tab._id].html"
|
||||||
|
x-cloak>
|
||||||
|
{{ __("activitypub.reader.pagination.noMore") }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{# Infinite scroll sentinel — watched by IntersectionObserver #}
|
||||||
|
<div class="ap-tab-sentinel"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
{# ── Hashtag tab panel ────────────────────────────────────────────── #}
|
||||||
|
<template x-if="tab.type === 'hashtag'">
|
||||||
|
<div class="ap-explore-hashtag-panel">
|
||||||
|
|
||||||
|
{# Source info line — shows which instances are being searched #}
|
||||||
|
<p class="ap-hashtag-sources"
|
||||||
|
x-show="tabState[tab._id] && tabState[tab._id].sourceMeta && tabState[tab._id].sourceMeta.instancesQueried > 0"
|
||||||
|
x-text="hashtagSourcesLine(tab)"
|
||||||
|
x-cloak></p>
|
||||||
|
|
||||||
|
{# Loading spinner — first load, no content yet #}
|
||||||
|
<div class="ap-explore-tab-loading"
|
||||||
|
x-show="tabState[tab._id] && tabState[tab._id].loading && !tabState[tab._id].html">
|
||||||
|
<span class="ap-explore-tab-loading__text">{{ __("activitypub.reader.pagination.loading") }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Error state with retry #}
|
||||||
|
<div class="ap-explore-tab-error"
|
||||||
|
x-show="tabState[tab._id] && tabState[tab._id].error && !tabState[tab._id].html">
|
||||||
|
<p class="ap-explore-tab-error__message" x-text="tabState[tab._id] && tabState[tab._id].error"></p>
|
||||||
|
<button type="button" class="ap-explore-tab-error__retry" @click="retryTab(tab)">
|
||||||
|
{{ __("activitypub.reader.explore.tabs.retry") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Timeline content #}
|
||||||
|
<div class="ap-timeline ap-explore-tab-timeline"
|
||||||
|
x-show="tabState[tab._id] && tabState[tab._id].html"
|
||||||
|
x-html="tabState[tab._id] ? tabState[tab._id].html : ''">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Inline loading spinner for subsequent pages #}
|
||||||
|
<div class="ap-explore-tab-loading ap-explore-tab-loading--more"
|
||||||
|
x-show="tabState[tab._id] && tabState[tab._id].loading && tabState[tab._id].html">
|
||||||
|
<span class="ap-explore-tab-loading__text">{{ __("activitypub.reader.pagination.loading") }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Empty state — no instance tabs pinned yet #}
|
||||||
|
<div class="ap-explore-tab-empty"
|
||||||
|
x-show="tabState[tab._id] && tabState[tab._id].done && !tabState[tab._id].html && !tabState[tab._id].loading && !tabState[tab._id].error">
|
||||||
|
<p>{{ __("activitypub.reader.explore.tabs.noInstances") }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# End of feed message #}
|
||||||
|
<p class="ap-load-more__done"
|
||||||
|
x-show="tabState[tab._id] && tabState[tab._id].done && tabState[tab._id].html"
|
||||||
|
x-cloak>
|
||||||
|
{{ __("activitypub.reader.pagination.noMore") }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{# Infinite scroll sentinel #}
|
||||||
|
<div class="ap-tab-sentinel"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
</template>
|
||||||
<div class="ap-deck-empty">
|
|
||||||
<p>{{ __("activitypub.reader.explore.deck.emptyState") }}</p>
|
</div>{# end ap-explore-tabs-container #}
|
||||||
<a href="{{ exploreBase }}" class="ap-deck-empty__link">
|
|
||||||
{{ __("activitypub.reader.explore.deck.emptyStateLink") }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}{# end Decks tab #}
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
<script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-infinite-scroll.js"></script>
|
<script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-infinite-scroll.js"></script>
|
||||||
{# Autocomplete components for explore + popular accounts #}
|
{# Autocomplete components for explore + popular accounts #}
|
||||||
<script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-autocomplete.js"></script>
|
<script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-autocomplete.js"></script>
|
||||||
{# Deck components — apDeckToggle and apDeckColumn #}
|
{# Tab components — apExploreTabs #}
|
||||||
<script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-decks.js"></script>
|
<script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-tabs.js"></script>
|
||||||
|
|
||||||
{# Alpine.js for client-side reactivity (CW toggles, interaction buttons, infinite scroll) #}
|
{# Alpine.js for client-side reactivity (CW toggles, interaction buttons, infinite scroll) #}
|
||||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js"></script>
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user