diff --git a/assets/reader-decks.js b/assets/reader-decks.js deleted file mode 100644 index a0d3783..0000000 --- a/assets/reader-decks.js +++ /dev/null @@ -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"; - } - }, - })); -}); diff --git a/assets/reader-infinite-scroll.js b/assets/reader-infinite-scroll.js index 1bee4eb..ae230ec 100644 --- a/assets/reader-infinite-scroll.js +++ b/assets/reader-infinite-scroll.js @@ -11,6 +11,7 @@ document.addEventListener("alpine:init", () => { maxId: null, instance: "", scope: "local", + hashtag: "", observer: null, init() { @@ -18,6 +19,7 @@ document.addEventListener("alpine:init", () => { this.maxId = el.dataset.maxId || null; this.instance = el.dataset.instance || ""; this.scope = el.dataset.scope || "local"; + this.hashtag = el.dataset.hashtag || ""; if (!this.maxId) { this.done = true; @@ -53,6 +55,10 @@ document.addEventListener("alpine:init", () => { scope: this.scope, max_id: this.maxId, }); + // Pass hashtag when in hashtag mode so infinite scroll stays on tag timeline + if (this.hashtag) { + params.set("hashtag", this.hashtag); + } try { const res = await fetch( diff --git a/assets/reader-tabs.js b/assets/reader-tabs.js new file mode 100644 index 0000000..e25191b --- /dev/null +++ b/assets/reader-tabs.js @@ -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 + } + }, + })); +}); diff --git a/assets/reader.css b/assets/reader.css index 1b688e2..72e578e 100644 --- a/assets/reader.css +++ b/assets/reader.css @@ -2060,189 +2060,294 @@ font-weight: 600; } -/* ---------- Explore: deck toggle button ---------- */ +/* ========================================================================== + Explore: Tabbed Design + ========================================================================== */ -.ap-explore-deck-toggle { - display: flex; - justify-content: flex-end; - margin-bottom: var(--space-s); +/* Tab bar wrapper: enables position:relative for fade gradient overlay */ +.ap-explore-tabs-container { + position: relative; } -.ap-explore-deck-toggle__btn { - align-items: center; - background: none; - border: var(--border-width-thin) solid var(--color-outline); - border-radius: var(--border-radius-small); - color: var(--color-on-background); - cursor: pointer; +/* Tab bar with right-edge fade to indicate horizontal overflow */ +.ap-explore-tabs-nav { + padding-right: var(--space-l); + position: relative; +} + +.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; - font-size: var(--font-size-s); - gap: var(--space-2xs); - padding: var(--space-xs) var(--space-s); - transition: background 0.15s, color 0.15s, border-color 0.15s; + position: relative; } -.ap-explore-deck-toggle__btn:hover:not(:disabled) { - background: var(--color-offset); -} - -.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 { +/* Show controls on hover or when the tab is active */ +.ap-tab-controls { align-items: center; - background: var(--color-background); - border-bottom: var(--border-width-thin) solid var(--color-outline); - display: flex; - flex-shrink: 0; - gap: var(--space-xs); - padding: var(--space-xs) var(--space-s); + display: none; + gap: 1px; } -.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-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; text-overflow: ellipsis; white-space: nowrap; } -.ap-deck-column__scope-badge { - border-radius: var(--border-radius-small); - flex-shrink: 0; - font-size: var(--font-size-xs); - font-weight: 600; - padding: 2px var(--space-xs); +/* Scope badges on instance tabs */ +.ap-tab__badge { + border-radius: 3px; + font-size: 0.65em; + font-weight: 700; + letter-spacing: 0.02em; + margin-left: var(--space-xs); + padding: 1px 4px; text-transform: uppercase; + vertical-align: middle; } -.ap-deck-column__scope-badge--local { - background: var(--color-blue10, #dbeafe); - color: var(--color-blue50, #1e40af); +.ap-tab__badge--local { + background: color-mix(in srgb, var(--color-blue40, #2563eb) 15%, transparent); + color: var(--color-blue40, #2563eb); } -.ap-deck-column__scope-badge--federated { - background: var(--color-purple10, #ede9fe); - color: var(--color-purple50, #5b21b6); +.ap-tab__badge--federated { + background: color-mix(in srgb, var(--color-purple45, #7c3aed) 15%, transparent); + color: var(--color-purple45, #7c3aed); } -.ap-deck-column__remove { - background: none; - border: none; +/* +# button for adding hashtag tabs */ +.ap-tab--add { + 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); - cursor: pointer; - flex-shrink: 0; - font-size: 1.2rem; - line-height: 1; - margin-left: auto; - padding: 0 2px; + font-weight: 600; } -.ap-deck-column__remove:hover { - 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; +.ap-tab-hashtag-form__input { border: var(--border-width-thin) solid var(--color-outline); 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; + font-family: inherit; font-size: var(--font-size-s); - margin-top: var(--space-xs); - padding: var(--space-xs) var(--space-s); + padding: 2px var(--space-s); + white-space: nowrap; } -.ap-deck-column__retry:hover { - background: var(--color-offset); +.ap-tab-hashtag-form__btn:hover { + opacity: 0.85; } -/* Cards inside deck columns are more compact */ -.ap-deck-column__items .ap-item-card { - 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); +/* "Pin as tab" button in search results area */ +.ap-explore-pin-bar { 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); } -/* ---------- Deck responsive ---------- */ - -@media (max-width: 767px) { - .ap-deck-grid { - grid-template-columns: 1fr; - } - - .ap-deck-column { - max-height: 60vh; - } +/* Error state */ +.ap-explore-tab-error { + align-items: center; + display: flex; + flex-direction: column; + gap: var(--space-s); + padding: var(--space-xl); } + +.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); +} + diff --git a/index.js b/index.js index 53412ca..9b0fb26 100644 --- a/index.js +++ b/index.js @@ -70,10 +70,12 @@ import { } from "./lib/controllers/explore.js"; import { followTagController, unfollowTagController } from "./lib/controllers/follow-tag.js"; import { - listDecksController, - addDeckController, - removeDeckController, -} from "./lib/controllers/decks.js"; + listTabsController, + addTabController, + removeTabController, + reorderTabsController, +} from "./lib/controllers/tabs.js"; +import { hashtagExploreApiController } from "./lib/controllers/hashtag-explore.js"; import { publicProfileController } from "./lib/controllers/public-profile.js"; import { authorizeInteractionController } from "./lib/controllers/authorize-interaction.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/explore", exploreController(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/instance-check", instanceCheckApiController(mp)); router.get("/admin/reader/api/popular-accounts", popularAccountsApiController(mp)); - router.get("/admin/reader/api/decks", listDecksController(mp)); - router.post("/admin/reader/api/decks", addDeckController(mp)); - router.post("/admin/reader/api/decks/remove", removeDeckController(mp)); + router.get("/admin/reader/api/tabs", listTabsController(mp)); + router.post("/admin/reader/api/tabs", addTabController(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/unfollow-tag", unfollowTagController(mp)); router.get("/admin/reader/notifications", notificationsController(mp)); @@ -884,8 +888,8 @@ export default class ActivityPubEndpoint { Indiekit.addCollection("ap_interactions"); Indiekit.addCollection("ap_notes"); Indiekit.addCollection("ap_followed_tags"); - // Deck collections - Indiekit.addCollection("ap_decks"); + // Explore tab collections + Indiekit.addCollection("ap_explore_tabs"); // Store collection references (posts resolved lazily) const indiekitCollections = Indiekit.collections; @@ -906,8 +910,8 @@ export default class ActivityPubEndpoint { ap_interactions: indiekitCollections.get("ap_interactions"), ap_notes: indiekitCollections.get("ap_notes"), ap_followed_tags: indiekitCollections.get("ap_followed_tags"), - // Deck collections - ap_decks: indiekitCollections.get("ap_decks"), + // Explore tab collections + ap_explore_tabs: indiekitCollections.get("ap_explore_tabs"), get posts() { return indiekitCollections.get("posts"); }, @@ -1032,11 +1036,19 @@ export default class ActivityPubEndpoint { { background: true }, ); - // Deck index — compound unique ensures same instance can appear at most once per scope - this._collections.ap_decks.createIndex( - { domain: 1, scope: 1 }, + // Explore tab indexes + // Compound unique on (type, domain, scope, hashtag) prevents duplicate tabs. + // 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 }, ); + // Order index for efficient sorting of tab bar + this._collections.ap_explore_tabs.createIndex( + { order: 1 }, + { background: true }, + ); } catch { // Index creation failed — collections not yet available. // Indexes already exist from previous startups; non-fatal. diff --git a/lib/controllers/decks.js b/lib/controllers/decks.js deleted file mode 100644 index 09003b6..0000000 --- a/lib/controllers/decks.js +++ /dev/null @@ -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); - } - }; -} diff --git a/lib/controllers/explore-utils.js b/lib/controllers/explore-utils.js new file mode 100644 index 0000000..7907cf1 --- /dev/null +++ b/lib/controllers/explore-utils.js @@ -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, + }; +} diff --git a/lib/controllers/explore.js b/lib/controllers/explore.js index 6b424f7..7b81335 100644 --- a/lib/controllers/explore.js +++ b/lib/controllers/explore.js @@ -5,117 +5,15 @@ * 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 { getToken } from "../csrf.js"; +import { validateInstance, validateHashtag, mapMastodonStatusToItem } from "./explore-utils.js"; const FETCH_TIMEOUT_MS = 10_000; const MAX_RESULTS = 20; -/** - * Validate the instance parameter to prevent SSRF. - * 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, - }; -} +// Re-export validateInstance for backward compatibility (used by tabs.js, index.js) +export { validateInstance } from "./explore-utils.js"; export function exploreController(mountPath) { return async (request, response, next) => { @@ -123,24 +21,10 @@ export function exploreController(mountPath) { const rawInstance = request.query.instance || ""; const scope = request.query.scope === "federated" ? "federated" : "local"; const maxId = request.query.max_id || ""; - const activeTab = request.query.tab === "decks" ? "decks" : "search"; - - // 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 rawHashtag = request.query.hashtag || ""; + const hashtag = rawHashtag ? validateHashtag(rawHashtag) : null; const csrfToken = getToken(request.session); - const deckCount = decks.length; - const readerParent = { href: `${mountPath}/admin/reader`, text: response.locals.__("activitypub.reader.title") }; // No instance specified — render clean initial page (no error) @@ -150,14 +34,11 @@ export function exploreController(mountPath) { readerParent, instance: "", scope, + hashtag: hashtag || "", items: [], maxId: null, error: null, mountPath, - activeTab, - decks, - deckCount, - isInDeck: false, csrfToken, }); } @@ -169,22 +50,25 @@ export function exploreController(mountPath) { readerParent, instance: rawInstance, scope, + hashtag: hashtag || "", items: [], maxId: null, error: response.locals.__("activitypub.reader.explore.invalidInstance"), mountPath, - activeTab, - decks, - deckCount, - isInDeck: false, csrfToken, }); } - // Fetch public timeline from remote instance + // Build API URL: hashtag timeline or public timeline const isLocal = scope === "local"; - const apiUrl = new URL(`https://${instance}/api/v1/timelines/public`); - apiUrl.searchParams.set("local", isLocal ? "true" : "false"); + let apiUrl; + 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)); if (maxId) apiUrl.searchParams.set("max_id", maxId); @@ -226,23 +110,16 @@ export function exploreController(mountPath) { error = msg; } - const isInDeck = decks.some( - (d) => d.domain === instance && d.scope === scope, - ); - response.render("activitypub-explore", { title: response.locals.__("activitypub.reader.explore.title"), readerParent, instance, scope, + hashtag: hashtag || "", items, maxId: nextMaxId, error, mountPath, - activeTab, - decks, - deckCount, - isInDeck, csrfToken, // Pass empty interactionMap — explore posts are not in our DB interactionMap: {}, @@ -263,15 +140,24 @@ export function exploreApiController(mountPath) { const rawInstance = request.query.instance || ""; const scope = request.query.scope === "federated" ? "federated" : "local"; const maxId = request.query.max_id || ""; + const rawHashtag = request.query.hashtag || ""; + const hashtag = rawHashtag ? validateHashtag(rawHashtag) : null; const instance = validateInstance(rawInstance); if (!instance) { return response.status(400).json({ error: "Invalid instance" }); } + // Build API URL: hashtag timeline or public timeline const isLocal = scope === "local"; - const apiUrl = new URL(`https://${instance}/api/v1/timelines/public`); - apiUrl.searchParams.set("local", isLocal ? "true" : "false"); + let apiUrl; + 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)); if (maxId) apiUrl.searchParams.set("max_id", maxId); diff --git a/lib/controllers/hashtag-explore.js b/lib/controllers/hashtag-explore.js new file mode 100644 index 0000000..625c324 --- /dev/null +++ b/lib/controllers/hashtag-explore.js @@ -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); + } + }; +} diff --git a/lib/controllers/tabs.js b/lib/controllers/tabs.js new file mode 100644 index 0000000..4d5fba1 --- /dev/null +++ b/lib/controllers/tabs.js @@ -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: ["", "", ...] } + * 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); + } + }; +} diff --git a/locales/en.json b/locales/en.json index b235d80..492947d 100644 --- a/locales/en.json +++ b/locales/en.json @@ -240,22 +240,25 @@ "mauLabel": "MAU", "timelineSupported": "Public timeline available", "timelineUnsupported": "Public timeline not available", + "hashtagLabel": "Hashtag (optional)", + "hashtagPlaceholder": "e.g. indieweb", + "hashtagHint": "Filter results to a specific hashtag", "tabs": { + "label": "Explore tabs", "search": "Search", - "decks": "Decks" - }, - "deck": { - "addToDeck": "Add to deck", - "removeFromDeck": "Remove from deck", - "inDeck": "In deck", - "deckLimitReached": "Maximum of 8 decks reached", - "localBadge": "Local", - "federatedBadge": "Federated", - "removeColumn": "Remove column", + "pinAsTab": "Pin as tab", + "pinned": "Pinned", + "remove": "Remove tab", + "moveUp": "Move up", + "moveDown": "Move down", + "addHashtag": "Add hashtag tab", + "hashtagTabPlaceholder": "Enter hashtag", + "addTab": "Add", "retry": "Retry", - "loadError": "Could not load timeline from this instance.", - "emptyState": "No decks yet. Browse an instance in the Search tab and click the star to add it.", - "emptyStateLink": "Go to Search" + "noInstances": "Pin some instances first to use hashtag tabs.", + "sources": "Searching #%s across %d instance", + "sources_plural": "Searching #%s across %d instances", + "sourcesPartial": "%d of %d instances responded" } }, "tagTimeline": { diff --git a/views/activitypub-explore.njk b/views/activitypub-explore.njk index a84e38a..90cd224 100644 --- a/views/activitypub-explore.njk +++ b/views/activitypub-explore.njk @@ -9,210 +9,381 @@

{{ __("activitypub.reader.explore.description") }}

- {# Tab navigation #} - {% set exploreBase = mountPath + "/admin/reader/explore" %} - + {# ── Tabbed explore container (Alpine.js component) ──────────────────── #} +
- {# ── Search tab ────────────────────────────────────────────────── #} - {% if activeTab != 'decks' %} + {# ── Tab bar ──────────────────────────────────────────────────────────── #} +
+ {# end tab bar nav #} - {% endif %}{# end Search tab #} + {# Error message (CSRF expiry, network errors) #} +

- {# ── Decks tab ──────────────────────────────────────────────────── #} - {% if activeTab == 'decks' %} - {% if decks and decks.length > 0 %} -
- {% for deck in decks %} -
-
- {{ deck.domain }} - - {{ __("activitypub.reader.explore.deck." + deck.scope + "Badge") }} - - -
-
-
- {{ __("activitypub.reader.pagination.loading") }} -
-
-

- +
+
+ + # + + {{ __("activitypub.reader.explore.hashtagHint") }} +
+ + + {# Error state #} + {% if error %} +
{{ error }}
+ {% endif %} + + {# Results #} + {% if instance and not error %} + {# Pin as tab button — outside form, inside apExploreTabs scope #} +
+ +
+ + {% if items.length > 0 %} +
+ {% for item in items %} + {% include "partials/ap-item-card.njk" %} + {% endfor %} +
+ + {# Infinite scroll for explore search tab #} + {% if maxId %} +
+
+ +

{{ __("activitypub.reader.pagination.noMore") }}

+
+ {% endif %} + {% elif instance %} + {{ prose({ text: __("activitypub.reader.explore.noResults") }) }} + {% endif %} + {% endif %} + +
{# end Search tab panel #} + + {# ── Dynamic tab panels (instance + hashtag) ──────────────────────────── #} + + +
{# end ap-explore-tabs-container #} {% endblock %} diff --git a/views/layouts/ap-reader.njk b/views/layouts/ap-reader.njk index 6698b54..96c897c 100644 --- a/views/layouts/ap-reader.njk +++ b/views/layouts/ap-reader.njk @@ -5,8 +5,8 @@ {# Autocomplete components for explore + popular accounts #} - {# Deck components — apDeckToggle and apDeckColumn #} - + {# Tab components — apExploreTabs #} + {# Alpine.js for client-side reactivity (CW toggles, interaction buttons, infinite scroll) #}