From 80a7e6c20b7c9a2f291b17dded0c1a7fd63a5da9 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 18 Apr 2026 14:26:28 -0400 Subject: [PATCH] =?UTF-8?q?feat(rsocials):=20timeline=20view=20=E2=80=94?= =?UTF-8?q?=20zoomable=20per-platform=20schedule,=20day+hour=20ticks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Grid / Timeline toggle on the rSocials gallery. Timeline lays out scheduled posts on a horizontal time axis with one lane per platform (X / LinkedIn / Instagram / Threads / Bluesky / YouTube / Newsletter). Zoomable runway: - Base 30 px/hr, zoom range 0.15×–4× (≈ month overview to single-hour detail) - Ctrl/Cmd+wheel or trackpad pinch zooms at the cursor anchor; plain scroll-wheel pans horizontally. +/- buttons step 25%, ⊡ Fit auto- sizes to all visible posts - Adaptive tick density: day-only when <15 px/hr, every 12h, 6h, 3h, or 1h as zoom increases - Day markers always shown; hour ticks appear past the density gate - Sticky time-axis header and sticky left gutter with platform labels - Today marker rendered as a red vertical line + "Now" chip when the current time is in range; 📍 Now button scrolls to it Cards are absolute-positioned by exact scheduledAt, show platform icon, formatted time, thread count badge, and a 2-line preview; click still opens the same post detail overlay. State persistence: _viewMode, _timelineZoom, and _timelineScrollLeft all survive re-renders + reloads via localStorage. Cache bump folk-thread-gallery to v=6. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/folk-thread-gallery.ts | 493 +++++++++++++++++- modules/rsocials/mod.ts | 4 +- 2 files changed, 488 insertions(+), 9 deletions(-) diff --git a/modules/rsocials/components/folk-thread-gallery.ts b/modules/rsocials/components/folk-thread-gallery.ts index a4cb1b2e..00c3bb36 100644 --- a/modules/rsocials/components/folk-thread-gallery.ts +++ b/modules/rsocials/components/folk-thread-gallery.ts @@ -24,7 +24,48 @@ interface DraftPostCard { } type StatusFilter = 'draft' | 'scheduled' | 'published'; +type ViewMode = 'grid' | 'timeline'; const STATUS_STORAGE_KEY = 'rsocials:gallery:status-filter'; +const VIEW_STORAGE_KEY = 'rsocials:gallery:view-mode'; +const TIMELINE_ZOOM_STORAGE_KEY = 'rsocials:gallery:timeline-zoom'; + +// Timeline constants +const TIMELINE_BASE_PX_PER_HOUR = 30; // baseline; multiplied by zoom +const TIMELINE_ZOOM_MIN = 0.15; // ~week+ visible on screen +const TIMELINE_ZOOM_MAX = 4; // single-hour detail +const TIMELINE_LANE_HEIGHT = 84; +const TIMELINE_CARD_W = 200; +const TIMELINE_CARD_H = 72; +const TIMELINE_HEADER_H = 56; +const TIMELINE_LEFT_GUTTER = 110; +const TIMELINE_PLATFORMS = ['x', 'linkedin', 'instagram', 'threads', 'bluesky', 'youtube', 'newsletter', 'other'] as const; + +const PLATFORM_LANE_LABELS: Record = { + x: '𝕏 X', + linkedin: '💼 LinkedIn', + instagram: '📷 Instagram', + threads: '🧵 Threads', + bluesky: '🦋 Bluesky', + youtube: '📹 YouTube', + newsletter: '📧 Newsletter', + other: '📱 Other', +}; + +function platformLaneKey(platform: string): string { + const p = platform.toLowerCase(); + if (p === 'twitter') return 'x'; + if ((TIMELINE_PLATFORMS as readonly string[]).includes(p)) return p; + return 'other'; +} + +/** Pick the tick spacing (in hours) that keeps labels readable at a given zoom. */ +function tickHoursForZoom(pxPerHour: number): number { + if (pxPerHour >= 80) return 1; + if (pxPerHour >= 40) return 3; + if (pxPerHour >= 20) return 6; + if (pxPerHour >= 10) return 12; + return 24; // day-only at very zoomed-out +} interface EditDraft { tweets: string[]; // thread-style: 1 element for single post, N for chains @@ -52,18 +93,28 @@ export class FolkThreadGallery extends HTMLElement { private _editDraft: EditDraft | null = null; private _savingIndicator = ''; private _boundKeyDown: ((e: KeyboardEvent) => void) | null = null; + private _viewMode: ViewMode = 'grid'; + private _timelineZoom = 1; // multiplier on TIMELINE_BASE_PX_PER_HOUR + private _timelineScrollLeft = 0; // preserved across re-renders + private _pendingTimelineScrollToNow = false; static get observedAttributes() { return ['space']; } connectedCallback() { if (!this.shadowRoot) this.attachShadow({ mode: 'open' }); this._space = this.getAttribute('space') || 'demo'; - // Restore last status filter + // Restore last status filter + view mode + timeline zoom try { const saved = localStorage.getItem(STATUS_STORAGE_KEY); if (saved === 'draft' || saved === 'scheduled' || saved === 'published') { this._statusFilter = saved; } + const view = localStorage.getItem(VIEW_STORAGE_KEY); + if (view === 'grid' || view === 'timeline') this._viewMode = view; + const zoom = parseFloat(localStorage.getItem(TIMELINE_ZOOM_STORAGE_KEY) || ''); + if (isFinite(zoom) && zoom >= TIMELINE_ZOOM_MIN && zoom <= TIMELINE_ZOOM_MAX) { + this._timelineZoom = zoom; + } } catch { /* ignore */ } this._boundKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape' && this._expandedPostId) { @@ -220,17 +271,24 @@ export class FolkThreadGallery extends HTMLElement { ${chip('published', 'Published')} `; + const viewToggle = `
+ + +
`; + // Threads render alongside published posts (they have no draft state). const threadsVisible = filter === 'published' ? threads : []; - const threadCardsHTML = filteredPosts.length === 0 && threadsVisible.length === 0 - ? `
+ const emptyHtml = `

No ${filter} posts. Create your first post or thread.

-
` +
`; + + const gridHtml = filteredPosts.length === 0 && threadsVisible.length === 0 + ? emptyHtml : `
${filteredPosts.map(p => { const preview = this.esc(p.content.substring(0, 200)); @@ -280,6 +338,9 @@ export class FolkThreadGallery extends HTMLElement { }).join('')}
`; + const timelineHtml = this._viewMode === 'timeline' ? this.renderTimeline(filteredPosts) : ''; + const bodyHtml = this._viewMode === 'timeline' ? timelineHtml : gridHtml; + this.shadowRoot.innerHTML = ` -