mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
feat: add TweetDeck-style deck layout for explore view
Users can favorite instances (with local or federated scope) as persistent columns in a multi-column deck view. Each column streams its own public timeline with independent infinite scroll. Includes two-tab explore UI (Search + Decks), deck CRUD API with CSRF/SSRF protection, 8-deck limit, responsive CSS Grid layout, and scope badges.
This commit is contained in:
212
assets/reader-decks.js
Normal file
212
assets/reader-decks.js
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* 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";
|
||||
}
|
||||
},
|
||||
}));
|
||||
});
|
||||
@@ -15,14 +15,15 @@
|
||||
}
|
||||
|
||||
.ap-lookup__input {
|
||||
flex: 1;
|
||||
padding: var(--space-s) var(--space-m);
|
||||
border: var(--border-width-thin) solid var(--color-outline);
|
||||
border-radius: var(--border-radius-small);
|
||||
background: var(--color-offset);
|
||||
box-sizing: border-box;
|
||||
color: var(--color-on-background);
|
||||
font-size: var(--font-size-m);
|
||||
font-family: inherit;
|
||||
font-size: var(--font-size-m);
|
||||
padding: var(--space-s) var(--space-m);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ap-lookup__input::placeholder {
|
||||
@@ -1784,10 +1785,11 @@
|
||||
.ap-explore-form__input {
|
||||
border: var(--border-width-thin) solid var(--color-outline);
|
||||
border-radius: var(--border-radius-small);
|
||||
flex: 1;
|
||||
box-sizing: border-box;
|
||||
font-size: var(--font-size-base);
|
||||
min-width: 0;
|
||||
padding: var(--space-xs) var(--space-s);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ap-explore-form__scope {
|
||||
@@ -2020,3 +2022,190 @@
|
||||
color: var(--color-on-offset);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---------- Explore: deck toggle button ---------- */
|
||||
|
||||
.ap-explore-deck-toggle {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: var(--space-s);
|
||||
}
|
||||
|
||||
.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;
|
||||
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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
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);
|
||||
}
|
||||
|
||||
.ap-deck-column__domain {
|
||||
font-size: var(--font-size-s);
|
||||
font-weight: 600;
|
||||
min-width: 0;
|
||||
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);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ap-deck-column__scope-badge--local {
|
||||
background: var(--color-blue10, #dbeafe);
|
||||
color: var(--color-blue50, #1e40af);
|
||||
}
|
||||
|
||||
.ap-deck-column__scope-badge--federated {
|
||||
background: var(--color-purple10, #ede9fe);
|
||||
color: var(--color-purple50, #5b21b6);
|
||||
}
|
||||
|
||||
.ap-deck-column__remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-on-offset);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
margin-left: auto;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.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;
|
||||
border: var(--border-width-thin) solid var(--color-outline);
|
||||
border-radius: var(--border-radius-small);
|
||||
color: var(--color-on-background);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-s);
|
||||
margin-top: var(--space-xs);
|
||||
padding: var(--space-xs) var(--space-s);
|
||||
}
|
||||
|
||||
.ap-deck-column__retry:hover {
|
||||
background: var(--color-offset);
|
||||
}
|
||||
|
||||
/* 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);
|
||||
margin-bottom: var(--space-s);
|
||||
}
|
||||
|
||||
.ap-deck-empty__link {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user