diff --git a/assets/reader-decks.js b/assets/reader-decks.js new file mode 100644 index 0000000..a0d3783 --- /dev/null +++ b/assets/reader-decks.js @@ -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"; + } + }, + })); +}); diff --git a/assets/reader.css b/assets/reader.css index b275aac..b52ce3e 100644 --- a/assets/reader.css +++ b/assets/reader.css @@ -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; + } +} diff --git a/docs/plans/2026-02-27-activitypub-deck-layout.md b/docs/plans/2026-02-27-activitypub-deck-layout.md new file mode 100644 index 0000000..930e30a --- /dev/null +++ b/docs/plans/2026-02-27-activitypub-deck-layout.md @@ -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-__--` (BEM-like) + - Template naming: `activitypub-.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 ` {# Autocomplete components for explore + popular accounts #} + {# Deck components — apDeckToggle and apDeckColumn #} + {# Alpine.js for client-side reactivity (CW toggles, interaction buttons, infinite scroll) #}