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:
Ricardo
2026-02-27 11:24:53 +01:00
parent 525abcbf84
commit 145e329d2f
9 changed files with 1108 additions and 7 deletions

212
assets/reader-decks.js Normal file
View 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";
}
},
}));
});

View File

@@ -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;
}
}

View File

@@ -0,0 +1,398 @@
# ActivityPub Deck Layout Implementation Plan
Created: 2026-02-27
Status: VERIFIED
Approved: Yes
Iterations: 0
Worktree: No
> **Status Lifecycle:** PENDING → COMPLETE → VERIFIED
> **Iterations:** Tracks implement→verify cycles (incremented by verify phase)
>
> - PENDING: Initial state, awaiting implementation
> - COMPLETE: All tasks implemented
> - VERIFIED: All checks passed
>
> **Approval Gate:** Implementation CANNOT proceed until `Approved: Yes`
> **Worktree:** No — works directly on current branch
## Summary
**Goal:** Add a TweetDeck-style multi-column deck layout to the ActivityPub explore view. Users can favorite/bookmark instances (with local or federated scope) and see them as persistent columns in a deck view, each streaming its own public timeline. The same instance can appear twice with different scopes. Also includes responsive CSS fixes for input fields.
**Architecture:** The explore page gets a two-tab UI: "Search" (existing browse-by-search) and "Decks" (multi-column layout of favorited instances). Favorited instances are stored in a new `ap_decks` MongoDB collection. Each deck column is an independent Alpine.js component that fetches timelines via the existing `/api/explore` AJAX endpoint. Deck CRUD is handled via JSON API endpoints. A "favorite" button on the search view lets users save the current instance+scope as a deck.
**Tech Stack:** Alpine.js (client-side reactivity), Express routes (API), MongoDB (persistence), CSS Grid (responsive multi-column layout), existing Mastodon-compatible public timeline API.
## Scope
### In Scope
- New `ap_decks` MongoDB collection for storing favorited instances
- CRUD API endpoints for deck management (add, remove, list)
- Two-tab explore page: "Search" tab (existing) and "Decks" tab (new)
- Multi-column deck layout with CSS Grid, responsive wrapping
- Each deck column loads its timeline via AJAX with infinite scroll
- "Add to deck" button on the search view when browsing an instance
- Visual badge for local vs federated scope on each column header
- Remove deck button on each column header
- Responsive CSS fix for `.ap-lookup__input` and `.ap-explore-form__input`
- i18n strings for all new UI elements
### Out of Scope
- Drag-and-drop column reordering (complex, future enhancement)
- Auto-refresh / live streaming of deck columns (future enhancement)
- Cross-column interactions (liking/boosting from deck columns)
- Custom column names (domain+scope is the label)
- Deck columns for non-Mastodon-compatible instances
## Prerequisites
- Existing explore controller with `exploreApiController` for AJAX timeline loading (`lib/controllers/explore.js`)
- Existing `apExploreScroll` Alpine.js component for infinite scroll (`assets/reader-infinite-scroll.js`)
- Alpine.js loaded via CDN in `views/layouts/ap-reader.njk`
- FediDB autocomplete already working on explore page
## Context for Implementer
> This section is critical for cross-session continuity.
- **Patterns to follow:**
- Route registration: Follow the pattern in `index.js:234` — all deck routes go in the `routes` getter (authenticated admin routes, behind IndieAuth)
- Controller factory pattern: All controllers are factory functions returning `(request, response, next)` — see `explore.js:119`
- MongoDB collection access: `request.app.locals.application.collections.get("ap_decks")` or pass via factory closure
- Alpine.js component registration: via `alpine:init` event, see `assets/reader-autocomplete.js:6`
- Infinite scroll: Deck columns must implement their OWN scroll handler (NOT reuse `apExploreScroll` — see gotcha below)
- CSS custom properties: Use Indiekit theme vars (`--color-*`, `--space-*`, `--border-*`) — see `assets/reader.css`
- **Conventions:**
- All controllers are ESM modules with named exports
- CSS class naming: `ap-<feature>__<element>--<modifier>` (BEM-like)
- Template naming: `activitypub-<feature>.njk` with `ap-reader.njk` layout
- i18n: All user-visible strings go in `locales/en.json` under `activitypub.reader.explore.deck.*`
- Dates stored as ISO 8601 strings: always `new Date().toISOString()`, never `new Date()` (CRITICAL — see CLAUDE.md)
- **Key files the implementer must read first:**
- `lib/controllers/explore.js` — Existing explore controller with timeline fetching, SSRF validation, Mastodon API mapping
- `views/activitypub-explore.njk` — Current explore template
- `assets/reader-infinite-scroll.js``apExploreScroll` Alpine component for explore infinite scroll
- `assets/reader-autocomplete.js``apInstanceSearch` Alpine component for autocomplete
- `index.js:226-238` — Route registration for explore endpoints (in the `routes` getter)
- `index.js:862-878` — MongoDB collection registration
- **Gotchas:**
- The explore API (`/api/explore`) already server-side renders card HTML via `request.app.render()` — deck columns can reuse this
- Template name collisions: Use `ap-` prefix for all new templates (see CLAUDE.md gotcha #7)
- Express 5 removed `redirect("back")` — always use explicit redirect paths
- The `validateInstance()` function in `explore.js` is NOT currently exported — Task 2 must export it before importing in `decks.js`
- Alpine components MUST load via `defer` script tags BEFORE the Alpine CDN script — `reader-decks.js` must be added before the Alpine CDN `<script>` in `ap-reader.njk`
- **`apExploreScroll` CANNOT be reused for deck columns:** It hardcodes `document.getElementById("ap-explore-timeline")` at line 48 — with multiple deck columns, `getElementById` only finds the first one. Deck columns MUST implement their own scroll handler using `this.$refs` or `this.$el.querySelector()` to reference the column's own container.
- **CSRF protection is required** on all deck CRUD endpoints. The codebase has `lib/csrf.js` with `getToken()` and `validateToken()`. The `exploreController` must pass `csrfToken: getToken(request.session)` to the template, and client-side `fetch()` calls must include the `X-CSRF-Token` header. Server-side endpoints must call `validateToken(request)` before processing.
- **Domain context:**
- "Local" timeline = posts from users who have accounts on that instance
- "Federated" timeline = all posts that instance's relay has seen from across the fediverse
- Mastodon API: `GET /api/v1/timelines/public?local=true|false&limit=N&max_id=ID`
- Not all instances support public timeline access (some return 401/422) — the FediDB instance-check endpoint already handles this
## Runtime Environment
- **Start command:** Part of Indiekit — `npm start` or deployed via Cloudron
- **Port:** 8080 (behind nginx on Cloudron)
- **Deploy path:** Published to npm, installed in `indiekit-cloudron/Dockerfile`
- **Health check:** Served via Indiekit's built-in health endpoint
- **Restart procedure:** `cloudron restart --app rmendes.net` or bump version + `npm publish` + `cloudron build`
## Progress Tracking
**MANDATORY: Update this checklist as tasks complete. Change `[ ]` to `[x]`.**
- [x] Task 1: Responsive CSS fix + deck collection setup
- [x] Task 2: Deck CRUD API endpoints
- [x] Task 3: Two-tab explore page layout
- [x] Task 4: "Add to deck" button on search view
- [x] Task 5: Deck column Alpine.js component
- [x] Task 6: Multi-column deck view with responsive grid
**Total Tasks:** 6 | **Completed:** 6 | **Remaining:** 0
## Implementation Tasks
### Task 1: Responsive CSS Fix + Deck Collection Setup
**Objective:** Commit the pending responsive CSS changes and register the new `ap_decks` MongoDB collection with proper indexes.
**Dependencies:** None
**Files:**
- Modify: `assets/reader.css` (already has uncommitted changes — commit the existing diff as-is)
- Modify: `index.js` (add `ap_decks` collection registration + index)
**Key Decisions / Notes:**
- The CSS diff changes `.ap-lookup__input` and `.ap-explore-form__input` from `flex: 1` to `width: 100%; box-sizing: border-box`, and also alphabetically reorders properties. Commit the existing diff without additional changes.
- New collection `ap_decks` stores deck entries: `{ domain, scope, addedAt }`
- `addedAt` MUST be stored as `new Date().toISOString()` per the date convention (never `new Date()`)
- Compound unique index on `{ domain: 1, scope: 1 }` allows same instance with different scopes
- Column order is determined by `addedAt` ascending (no separate position field — drag-and-drop reordering is out of scope)
- Collection registration follows the pattern at `index.js:862-878`
- Collection reference added to `this._collections` object at `index.js:882-903`
**Definition of Done:**
- [ ] `ap_decks` collection is registered in `index.js` init method
- [ ] `ap_decks` has compound unique index `{ domain: 1, scope: 1 }`
- [ ] `ap_decks` is added to `this._collections` for controller access
- [ ] Responsive CSS fix is included (commit existing diff as-is)
**Verify:**
- `grep "ap_decks" index.js` — collection registered and referenced
- Visual check: input fields span full width on explore and reader pages
### Task 2: Deck CRUD API Endpoints
**Objective:** Create API endpoints for managing deck entries: list all decks, add a deck, remove a deck. Export `validateInstance()` from `explore.js` for reuse.
**Dependencies:** Task 1
**Files:**
- Modify: `lib/controllers/explore.js` (export `validateInstance()`)
- Create: `lib/controllers/decks.js`
- Modify: `index.js` (import and register routes in the `routes` getter)
**Key Decisions / Notes:**
- First, export `validateInstance()` from `explore.js` by changing `function validateInstance` to `export function validateInstance`
- Three endpoints, all registered in the `routes` getter (authenticated via IndieAuth):
- `GET /admin/reader/api/decks` — returns all decks sorted by `addedAt` ascending
- `POST /admin/reader/api/decks` — body: `{ domain, scope }`. Validates domain via `validateInstance()`. Returns the created deck.
- `POST /admin/reader/api/decks/remove` — body: `{ domain, scope }`. Removes the deck entry. Returns `{ success: true }`. Uses POST instead of DELETE to avoid issues with request bodies being stripped by proxies/CDNs.
- Follow the controller factory pattern from `explore.js:119` — each endpoint is a factory function returning `(req, res, next)`
- Maximum 8 decks enforced: `POST /api/decks` returns 400 if user already has 8 or more
- **CSRF protection:** Both `POST /api/decks` and `POST /api/decks/remove` must call `validateToken(request)` from `lib/csrf.js` before processing. Return 403 if invalid.
**Definition of Done:**
- [ ] `validateInstance()` is exported from `explore.js` for reuse by deck endpoints
- [ ] `GET /api/decks` returns JSON array of decks sorted by addedAt
- [ ] `POST /api/decks` with `{ domain: "mastodon.social", scope: "local" }` creates a deck entry
- [ ] `POST /api/decks` with invalid domain returns 400 error
- [ ] `POST /api/decks` with duplicate domain+scope returns 409 conflict
- [ ] `POST /api/decks` returns 400 if user already has 8 or more decks
- [ ] `POST /api/decks/remove` with `{ domain, scope }` removes the entry
- [ ] All endpoints are registered in the `routes` getter (behind IndieAuth)
- [ ] All endpoints use `validateInstance()` for SSRF prevention
- [ ] `POST /api/decks` and `POST /api/decks/remove` validate CSRF token via `validateToken(request)` from `lib/csrf.js`
**Verify:**
- `curl` commands against the running instance to test CRUD operations
- `grep "api/decks" index.js` — routes registered in the `routes` getter
### Task 3: Two-Tab Explore Page Layout
**Objective:** Restructure the explore page with tab navigation: "Search" (existing functionality) and "Decks" (new deck view). Server-rendered tabs with URL parameter switching.
**Dependencies:** Task 1
**Files:**
- Modify: `views/activitypub-explore.njk`
- Modify: `lib/controllers/explore.js` (pass `decks` and `activeTab` to template)
- Modify: `assets/reader.css` (tab styles)
- Modify: `locales/en.json` (new i18n strings)
**Key Decisions / Notes:**
- Tab switching via `?tab=search|decks` query parameter, default "search"
- The `exploreController` fetches deck list from `ap_decks` and passes to template as `decks`
- The `exploreController` must also pass `csrfToken: getToken(request.session)` to the template so Alpine.js components can include it in `X-CSRF-Token` headers on fetch calls
- Tab CSS follows the existing notification tabs pattern (see `activitypub-notifications.njk` if available, or design from scratch using `ap-explore-tabs__*` class prefix)
- The "Decks" tab content is a container that the Alpine.js deck components (Task 5-6) will populate
- When `?tab=decks` and no decks exist, show an empty state message explaining how to add decks
**Definition of Done:**
- [ ] Explore page shows two tabs: "Search" and "Decks"
- [ ] Clicking "Search" tab shows `?tab=search` with existing explore UI
- [ ] Clicking "Decks" tab shows `?tab=decks` with deck container
- [ ] Active tab is visually highlighted
- [ ] "Decks" tab with no decks shows empty state message
- [ ] All new strings are in `locales/en.json`
**Verify:**
- Navigate to `/activitypub/admin/reader/explore` — see Search tab active by default
- Navigate to `/activitypub/admin/reader/explore?tab=decks` — see Decks tab active
- Check i18n strings present: `grep "deck" locales/en.json`
### Task 4: "Add to Deck" Button on Search View
**Objective:** Add a "favorite" / "Add to deck" button on the search results view that saves the current instance+scope as a deck column.
**Dependencies:** Task 2, Task 3
**Files:**
- Modify: `views/activitypub-explore.njk` (add star/favorite button)
- Modify: `assets/reader.css` (button styles)
- Create: `assets/reader-decks.js` (Alpine.js component for deck management)
- Modify: `views/layouts/ap-reader.njk` (add script tag for reader-decks.js — MUST be placed BEFORE the Alpine CDN script, alongside the other component scripts)
- Modify: `locales/en.json` (button labels)
**Key Decisions / Notes:**
- The button appears next to the "Browse" button when viewing an instance timeline (results are showing)
- Alpine.js `apDeckToggle` component: checks if current instance+scope is already a deck, shows filled/empty star
- On click: calls `POST /api/decks` or `POST /api/decks/remove` to toggle
- **CSRF token:** All fetch calls must include `X-CSRF-Token` header with the token from the template (passed via a `data-csrf-token` attribute on the component's container, populated by `{{ csrfToken }}` from the server)
- Visual feedback: star fills/empties, brief toast or inline feedback
- **Max deck limit enforcement:** The component must know the current deck count. When 8 decks exist and the instance is not already favorited, the star button should be disabled with a tooltip explaining the limit. The template must pass the deck count (or max-reached boolean) so the Alpine component can check.
- The new `reader-decks.js` file will hold both the deck toggle and the deck column components (Task 5)
- The `<script defer>` tag in `ap-reader.njk` MUST be placed before the Alpine CDN `<script>` so the component is registered via `alpine:init` before Alpine initializes
**Definition of Done:**
- [ ] Star button appears when browsing an instance timeline on the Search tab
- [ ] Clicking the star when not favorited calls `POST /api/decks` and fills the star
- [ ] Clicking the star when already favorited calls `POST /api/decks/remove` and empties the star
- [ ] Star state is correct on page load (pre-checked against existing decks)
- [ ] Button has appropriate aria-label and title text
- [ ] Fetch calls include `X-CSRF-Token` header with token from template
- [ ] Star button is disabled with tooltip when 8 decks already exist (and current instance is not already favorited)
- [ ] `reader-decks.js` script tag is placed before Alpine CDN script in `ap-reader.njk`
**Verify:**
- Browse mastodon.social local timeline → star button visible
- Click star → star fills, deck entry created (verify via `GET /api/decks`)
- Reload page → star is still filled
- Click star again → star empties, deck entry removed
### Task 5: Deck Column Alpine.js Component
**Objective:** Create the `apDeckColumn` Alpine.js component that loads a single instance's timeline into a scrollable column with infinite scroll.
**Dependencies:** Task 2
**Files:**
- Modify: `assets/reader-decks.js` (add `apDeckColumn` component)
**Key Decisions / Notes:**
- Each column is an independent Alpine.js component initialized with `domain` and `scope` props
- On init, fetches timeline from `GET /admin/reader/api/explore?instance={domain}&scope={scope}`
- Response includes `{ html, maxId }` — the HTML is server-rendered card markup
- Column maintains its own `maxId` for pagination, own `loading` and `done` states
- **Own scroll handler (NOT `apExploreScroll`):** The `apDeckColumn` component MUST implement its own IntersectionObserver-based scroll handler. The existing `apExploreScroll` uses `document.getElementById("ap-explore-timeline")` which only finds the first element — it fundamentally cannot work with multiple columns. The deck column component should use `this.$refs.sentinel` or `this.$el.querySelector('.ap-deck-column__sentinel')` to observe within its own container.
- Column header shows: instance domain, scope badge (Local/Federated), and a remove button
- Remove button calls `POST /api/decks/remove` (with CSRF token in `X-CSRF-Token` header) then removes the column from DOM
- Error handling: if instance is unreachable, show error message in column body with a "Retry" button that re-triggers the fetch
- Loading state: show spinner/skeleton while first batch loads
- **Staggered initial fetch:** Columns delay their initial fetch based on their index (column 0 = immediate, column 1 = 500ms, column 2 = 1000ms, etc.) to avoid thundering herd when many columns load simultaneously
**Definition of Done:**
- [ ] `apDeckColumn` component fetches and renders timeline items from remote instance
- [ ] Infinite scroll loads more items as user scrolls down in the column
- [ ] Column header shows domain name and scope badge
- [ ] Remove button removes deck from DB and removes column from DOM
- [ ] Loading spinner shown during initial fetch
- [ ] Error message shown if instance is unreachable, with a "Retry" button
- [ ] Scroll handler uses `this.$refs` or `this.$el.querySelector()` (NOT `document.getElementById`)
- [ ] Remove button sends CSRF token via `X-CSRF-Token` header
- [ ] Columns stagger their initial fetch with 500ms delay per column index
**Verify:**
- Add a deck for mastodon.social (local) → column loads timeline items
- Scroll to bottom of column → more items load
- Click remove → column disappears, `GET /api/decks` no longer includes it
### Task 6: Multi-Column Deck View with Responsive Grid
**Objective:** Build the deck view that renders all favorited instances as a multi-column layout using CSS Grid, with responsive behavior.
**Dependencies:** Task 3, Task 5
**Files:**
- Modify: `views/activitypub-explore.njk` (deck view section with column containers)
- Modify: `assets/reader.css` (CSS Grid layout, responsive breakpoints)
- Modify: `assets/reader-decks.js` (deck view initialization)
- Modify: `locales/en.json` (empty states, column labels)
**Key Decisions / Notes:**
- CSS Grid layout: `grid-template-columns: repeat(auto-fill, minmax(360px, 1fr))`
- Desktop: columns sit side-by-side (2-3 columns on wide screens)
- Tablet: 2 columns
- Mobile (<768px): single column, stacked vertically
- Each column has a fixed max-height with internal scrolling (`overflow-y: auto`)
- Column max-height: `calc(100vh - 200px)` (viewport minus header/tabs)
- Scope badge styling: "Local" gets a blue badge, "Federated" gets a purple badge
- Empty deck view: centered message with explanation and a link to the Search tab
- Column order follows `addedAt` ascending from `ap_decks` (oldest first)
- The deck view template renders column containers server-side (from `decks` data), but each column loads its content client-side via Alpine.js
- Column containers have `x-data="apDeckColumn('domain', 'scope', 'mountPath', index)"` attributes (index used for stagger delay)
**Definition of Done:**
- [ ] Deck view renders all favorited instances as columns
- [ ] Columns sit side-by-side on desktop (≥1024px)
- [ ] Columns stack vertically on mobile (<768px)
- [ ] Each column has its own scrollbar for long timelines
- [ ] Scope badges show "Local" (blue) or "Federated" (purple) per column
- [ ] Empty deck view shows helpful message with link to Search tab
- [ ] Column order matches addedAt ascending in `ap_decks`
**Verify:**
- Add 3 decks → see 3 columns on desktop
- Resize browser to mobile → columns stack
- Each column scrolls independently
- Empty decks view shows the empty state message
## Testing Strategy
- **Unit tests:** No automated test suite exists. Manual testing against real fediverse instances.
- **Integration tests:** Test deck CRUD API endpoints via `curl`:
- `POST /api/decks` with valid/invalid/duplicate data
- `GET /api/decks` returns correct list
- `POST /api/decks/remove` removes entries
- **Manual verification:**
1. Add 2-3 decks (mix of local/federated)
2. Switch to Decks tab — see columns
3. Scroll columns — infinite scroll works
4. Remove a deck from column header — column disappears
5. Add same instance with different scope — both columns appear
6. Resize browser — responsive layout works
7. Test on deployed Cloudron instance
## Risks and Mitigations
| Risk | Likelihood | Impact | Mitigation |
| ---- | ---------- | ------ | ---------- |
| Multiple columns fetching simultaneously may slow page load | Medium | Medium | Stagger initial column fetches with 500ms delay per column index (column 0 immediate, column 1 at 500ms, etc.) |
| Remote instances may block or rate-limit multiple simultaneous timeline requests | Low | Medium | Each column fetches independently with its own AbortController; timeout at 10s (existing FETCH_TIMEOUT_MS) |
| Large number of deck columns may cause layout issues | Low | Low | Cap maximum decks at 8; `POST /api/decks` returns 400 if limit reached |
| Instance timeline API format varies across Mastodon forks | Low | Medium | The existing `mapMastodonStatusToItem()` in `explore.js` already handles this; deck columns reuse same API |
| CSS Grid not supported in very old browsers | Very Low | Low | CSS Grid has >97% browser support; fallback is single-column layout (natural Grid behavior) |
## Open Questions
- None — requirements are clear from user description.
### Deferred Ideas
- Drag-and-drop column reordering
- Auto-refresh / live streaming of deck columns (WebSocket or polling)
- Deck column width customization
- Cross-column interactions (like/boost directly from deck columns without opening post)
- Deck sharing/export (export deck configuration)
- Deck presets (pre-configured sets of popular instances)

View File

@@ -69,6 +69,11 @@ import {
popularAccountsApiController,
} from "./lib/controllers/explore.js";
import { followTagController, unfollowTagController } from "./lib/controllers/follow-tag.js";
import {
listDecksController,
addDeckController,
removeDeckController,
} from "./lib/controllers/decks.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";
@@ -236,6 +241,9 @@ export default class ActivityPubEndpoint {
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.post("/admin/reader/follow-tag", followTagController(mp));
router.post("/admin/reader/unfollow-tag", unfollowTagController(mp));
router.get("/admin/reader/notifications", notificationsController(mp));
@@ -876,6 +884,8 @@ export default class ActivityPubEndpoint {
Indiekit.addCollection("ap_interactions");
Indiekit.addCollection("ap_notes");
Indiekit.addCollection("ap_followed_tags");
// Deck collections
Indiekit.addCollection("ap_decks");
// Store collection references (posts resolved lazily)
const indiekitCollections = Indiekit.collections;
@@ -896,6 +906,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"),
get posts() {
return indiekitCollections.get("posts");
},
@@ -1019,6 +1031,12 @@ export default class ActivityPubEndpoint {
{ category: 1, published: -1 },
{ 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 },
{ unique: true, background: true },
);
} catch {
// Index creation failed — collections not yet available.
// Indexes already exist from previous startups; non-fatal.

137
lib/controllers/decks.js Normal file
View File

@@ -0,0 +1,137 @@
/**
* 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);
}
};
}

View File

@@ -8,6 +8,7 @@
import sanitizeHtml from "sanitize-html";
import { sanitizeContent } from "../timeline-store.js";
import { searchInstances, checkInstanceTimeline, getPopularAccounts } from "../fedidb.js";
import { getToken } from "../csrf.js";
const FETCH_TIMEOUT_MS = 10_000;
const MAX_RESULTS = 20;
@@ -18,7 +19,7 @@ const MAX_RESULTS = 20;
* @param {string} instance - Raw instance parameter from query string
* @returns {string|null} Validated hostname or null
*/
function validateInstance(instance) {
export function validateInstance(instance) {
if (!instance || typeof instance !== "string") return null;
try {
@@ -122,6 +123,23 @@ 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 csrfToken = getToken(request.session);
const deckCount = decks.length;
// No instance specified — render clean initial page (no error)
if (!rawInstance.trim()) {
@@ -133,6 +151,11 @@ export function exploreController(mountPath) {
maxId: null,
error: null,
mountPath,
activeTab,
decks,
deckCount,
isInDeck: false,
csrfToken,
});
}
@@ -146,6 +169,11 @@ export function exploreController(mountPath) {
maxId: null,
error: response.locals.__("activitypub.reader.explore.invalidInstance"),
mountPath,
activeTab,
decks,
deckCount,
isInDeck: false,
csrfToken,
});
}
@@ -194,6 +222,10 @@ 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"),
instance,
@@ -202,9 +234,13 @@ export function exploreController(mountPath) {
maxId: nextMaxId,
error,
mountPath,
activeTab,
decks,
deckCount,
isInDeck,
csrfToken,
// Pass empty interactionMap — explore posts are not in our DB
interactionMap: {},
csrfToken: "",
});
} catch (error) {
next(error);

View File

@@ -239,7 +239,24 @@
"invalidInstance": "Invalid instance hostname. Please enter a valid domain name.",
"mauLabel": "MAU",
"timelineSupported": "Public timeline available",
"timelineUnsupported": "Public timeline not available"
"timelineUnsupported": "Public timeline not available",
"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",
"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"
}
},
"tagTimeline": {
"postsTagged": "%d posts",

View File

@@ -9,6 +9,23 @@
<p class="ap-explore-header__desc">{{ __("activitypub.reader.explore.description") }}</p>
</header>
{# Tab navigation #}
{% set exploreBase = mountPath + "/admin/reader/explore" %}
<nav class="ap-tabs">
<a href="{{ exploreBase }}" class="ap-tab{% if activeTab != 'decks' %} ap-tab--active{% endif %}">
{{ __("activitypub.reader.explore.tabs.search") }}
</a>
<a href="{{ exploreBase }}?tab=decks" class="ap-tab{% if activeTab == 'decks' %} ap-tab--active{% endif %}">
{{ __("activitypub.reader.explore.tabs.decks") }}
{% if decks and decks.length > 0 %}
<span class="ap-tab__count">{{ decks.length }}</span>
{% endif %}
</a>
</nav>
{# ── Search tab ────────────────────────────────────────────────── #}
{% if activeTab != 'decks' %}
{# Instance form with autocomplete #}
<form action="{{ mountPath }}/admin/reader/explore" method="get" class="ap-explore-form"
x-data="apInstanceSearch('{{ mountPath }}')"
@@ -90,6 +107,23 @@
{# Results #}
{% if instance and not error %}
{# Add to deck toggle button (shown when browsing results) #}
{% if items.length > 0 %}
<div class="ap-explore-deck-toggle"
x-data="apDeckToggle('{{ instance }}', '{{ scope }}', '{{ mountPath }}', '{{ csrfToken }}', {{ deckCount }}, {{ 'true' if isInDeck else 'false' }})">
<button
type="button"
class="ap-explore-deck-toggle__btn"
:class="{ 'ap-explore-deck-toggle__btn--active': inDeck }"
@click="toggle()"
:disabled="!inDeck && deckLimitReached"
:title="!inDeck && deckLimitReached ? '{{ __('activitypub.reader.explore.deck.deckLimitReached') }}' : (inDeck ? '{{ __('activitypub.reader.explore.deck.removeFromDeck') }}' : '{{ __('activitypub.reader.explore.deck.addToDeck') }}')"
:aria-label="!inDeck && deckLimitReached ? '{{ __('activitypub.reader.explore.deck.deckLimitReached') }}' : (inDeck ? '{{ __('activitypub.reader.explore.deck.removeFromDeck') }}' : '{{ __('activitypub.reader.explore.deck.addToDeck') }}')"
x-text="inDeck ? '★ {{ __('activitypub.reader.explore.deck.inDeck') }}' : '☆ {{ __('activitypub.reader.explore.deck.addToDeck') }}'">
</button>
</div>
{% endif %}
{% if items.length > 0 %}
<div class="ap-timeline ap-explore-timeline"
id="ap-explore-timeline"
@@ -123,4 +157,62 @@
{{ prose({ text: __("activitypub.reader.explore.noResults") }) }}
{% endif %}
{% endif %}
{% endif %}{# end Search tab #}
{# ── Decks tab ──────────────────────────────────────────────────── #}
{% if activeTab == 'decks' %}
{% if decks and decks.length > 0 %}
<div class="ap-deck-grid" data-csrf-token="{{ csrfToken }}">
{% for deck in decks %}
<div class="ap-deck-column"
x-data="apDeckColumn('{{ deck.domain }}', '{{ deck.scope }}', '{{ mountPath }}', {{ loop.index0 }}, '{{ csrfToken }}')"
x-init="init()">
<header class="ap-deck-column__header">
<span class="ap-deck-column__domain">{{ deck.domain }}</span>
<span class="ap-deck-column__scope-badge ap-deck-column__scope-badge--{{ deck.scope }}">
{{ __("activitypub.reader.explore.deck." + deck.scope + "Badge") }}
</span>
<button
type="button"
class="ap-deck-column__remove"
@click="removeDeck()"
title="{{ __('activitypub.reader.explore.deck.removeColumn') }}"
aria-label="{{ __('activitypub.reader.explore.deck.removeColumn') }}">×</button>
</header>
<div class="ap-deck-column__body" x-ref="body">
<div x-show="loading && itemCount === 0" class="ap-deck-column__loading">
<span>{{ __("activitypub.reader.pagination.loading") }}</span>
</div>
<div x-show="error" class="ap-deck-column__error" x-cloak>
<p x-text="error"></p>
<button type="button" class="ap-deck-column__retry" @click="retryLoad()">
{{ __("activitypub.reader.explore.deck.retry") }}
</button>
</div>
<div x-show="!loading && !error && itemCount === 0" class="ap-deck-column__empty" x-cloak>
{{ __("activitypub.reader.explore.noResults") }}
</div>
<div x-html="html" class="ap-deck-column__items"></div>
<div class="ap-deck-column__sentinel" x-ref="sentinel"></div>
<div x-show="loading && itemCount > 0" class="ap-deck-column__loading-more" x-cloak>
<span>{{ __("activitypub.reader.pagination.loading") }}</span>
</div>
<p x-show="done && itemCount > 0" class="ap-deck-column__done" x-cloak>
{{ __("activitypub.reader.pagination.noMore") }}
</p>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="ap-deck-empty">
<p>{{ __("activitypub.reader.explore.deck.emptyState") }}</p>
<a href="{{ exploreBase }}" class="ap-deck-empty__link">
{{ __("activitypub.reader.explore.deck.emptyStateLink") }}
</a>
</div>
{% endif %}
{% endif %}{# end Decks tab #}
{% endblock %}

View File

@@ -5,6 +5,8 @@
<script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-infinite-scroll.js"></script>
{# Autocomplete components for explore + popular accounts #}
<script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-autocomplete.js"></script>
{# Deck components — apDeckToggle and apDeckColumn #}
<script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-decks.js"></script>
{# Alpine.js for client-side reactivity (CW toggles, interaction buttons, infinite scroll) #}
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js"></script>