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.

Create Thread Open Campaigns
-
` +
`; + + 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 = ` -