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,
|
||||
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(
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/* ---------- 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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user