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;
|
||||
}
|
||||
}
|
||||
|
||||
398
docs/plans/2026-02-27-activitypub-deck-layout.md
Normal file
398
docs/plans/2026-02-27-activitypub-deck-layout.md
Normal 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)
|
||||
18
index.js
18
index.js
@@ -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
137
lib/controllers/decks.js
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user