/** * β€” Thread listing grid with cards. * * Subscribes to Automerge doc and renders all threads sorted by updatedAt. * Falls back to static demo previews when offline runtime is unavailable. */ import { socialsSchema, socialsDocId } from '../schemas'; import type { SocialsDoc, ThreadData, Campaign, CampaignPost } from '../schemas'; import type { DocumentId } from '../../../shared/local-first/document'; import { DEMO_FEED } from '../lib/types'; interface DraftPostCard { id: string; campaignId: string; campaignTitle: string; platform: string; content: string; scheduledAt: string; status: string; hashtags: string[]; threadId?: string; threadPosts?: string[]; } type StatusFilter = 'draft' | 'scheduled' | 'published'; const STATUS_STORAGE_KEY = 'rsocials:gallery:status-filter'; interface EditDraft { tweets: string[]; // thread-style: 1 element for single post, N for chains scheduledAt: string; hashtags: string; status: 'draft' | 'scheduled' | 'published'; } // Per-platform character limits (null = no practical limit shown in UI). const PLATFORM_LIMITS: Record = { x: 280, twitter: 280, bluesky: 300, threads: 500, linkedin: 3000, instagram: 2200, youtube: 5000, newsletter: null, }; export class FolkThreadGallery extends HTMLElement { private _space = 'demo'; private _threads: ThreadData[] = []; private _allPosts: DraftPostCard[] = []; private _offlineUnsub: (() => void) | null = null; private _subscribedDocIds: string[] = []; private _isDemoFallback = false; private _statusFilter: StatusFilter = 'draft'; private _expandedPostId: string | null = null; private _editMode = false; private _editDraft: EditDraft | null = null; private _savingIndicator = ''; private _boundKeyDown: ((e: KeyboardEvent) => void) | null = null; static get observedAttributes() { return ['space']; } connectedCallback() { if (!this.shadowRoot) this.attachShadow({ mode: 'open' }); this._space = this.getAttribute('space') || 'demo'; // Restore last status filter try { const saved = localStorage.getItem(STATUS_STORAGE_KEY); if (saved === 'draft' || saved === 'scheduled' || saved === 'published') { this._statusFilter = saved; } } catch { /* ignore */ } this._boundKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape' && this._expandedPostId) { e.preventDefault(); if (this._editMode) { this.exitEditMode(); } else { this.closeOverlay(); } } }; document.addEventListener('keydown', this._boundKeyDown); this.render(); this.subscribeOffline(); } disconnectedCallback() { this._offlineUnsub?.(); this._offlineUnsub = null; const runtime = (window as any).__rspaceOfflineRuntime; if (runtime) { for (const id of this._subscribedDocIds) runtime.unsubscribe(id); } this._subscribedDocIds = []; if (this._boundKeyDown) document.removeEventListener('keydown', this._boundKeyDown); this._boundKeyDown = null; } attributeChangedCallback(name: string, _old: string, val: string) { if (name === 'space') this._space = val; } private async subscribeOffline() { let runtime = (window as any).__rspaceOfflineRuntime; if (!runtime) { await new Promise(r => setTimeout(r, 200)); runtime = (window as any).__rspaceOfflineRuntime; } if (!runtime) { this.loadDemoFallback(); return; } if (!runtime.isInitialized && runtime.init) { try { await runtime.init(); } catch { /* already init'd */ } } if (!runtime.isInitialized) { this.loadDemoFallback(); return; } try { const docId = socialsDocId(this._space) as DocumentId; const doc = await runtime.subscribe(docId, socialsSchema); this._subscribedDocIds.push(docId); this.renderFromDoc(doc); this._offlineUnsub = runtime.onChange(docId, (updated: any) => { this.renderFromDoc(updated); }); } catch { this.loadDemoFallback(); } } private loadDemoFallback() { if (this._threads.length > 0) return; // already have data // No runtime available β€” show static demo content (not editable) this._threads = DEMO_FEED.slice(0, 3).map((item, i) => ({ id: `demo-${i}`, name: item.username.replace('@', ''), handle: item.username, title: item.content.substring(0, 60), tweets: [item.content], createdAt: Date.now() - (i + 1) * 86400000, updatedAt: Date.now() - i * 3600000, })); this._isDemoFallback = true; this.render(); } private renderFromDoc(doc: SocialsDoc) { if (!doc?.threads) return; this._isDemoFallback = false; this._threads = Object.values(doc.threads).sort((a, b) => b.updatedAt - a.updatedAt); // Extract all campaign posts across statuses so the status filter // can choose between drafts / scheduled / published. this._allPosts = []; if (doc.campaigns) { for (const campaign of Object.values(doc.campaigns)) { for (const post of campaign.posts || []) { this._allPosts.push({ id: post.id, campaignId: campaign.id, campaignTitle: campaign.title, platform: post.platform, content: post.content, scheduledAt: post.scheduledAt, status: post.status, hashtags: post.hashtags || [], threadId: post.threadId, threadPosts: post.threadPosts ? [...post.threadPosts] : undefined, }); } } } this.render(); } private get basePath() { const host = window.location.hostname; if (host.endsWith('.rspace.online') || host.endsWith('.rsocials.online')) { return '/rsocials/'; } return `/${this._space}/rsocials/`; } private esc(s: string): string { return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } private platformIcon(platform: string): string { const icons: Record = { x: '𝕏', twitter: '𝕏', linkedin: 'πŸ’Ό', instagram: 'πŸ“·', threads: '🧡', bluesky: 'πŸ¦‹', youtube: 'πŸ“Ή', newsletter: 'πŸ“§', }; return icons[platform.toLowerCase()] || 'πŸ“±'; } private render() { if (!this.shadowRoot) return; const threads = this._threads; const filter = this._statusFilter; // Filter + sort posts by status + recency const filteredPosts = this._allPosts .filter(p => p.status === filter) .sort((a, b) => { const aT = a.scheduledAt ? new Date(a.scheduledAt).getTime() : 0; const bT = b.scheduledAt ? new Date(b.scheduledAt).getTime() : 0; return bT - aT; }); // Status counts for chip badges const counts = { draft: this._allPosts.filter(p => p.status === 'draft').length, scheduled: this._allPosts.filter(p => p.status === 'scheduled').length, published: this._allPosts.filter(p => p.status === 'published').length, }; const chip = (key: StatusFilter, label: string) => ``; const filterBar = ``; // Threads render alongside published posts (they have no draft state). const threadsVisible = filter === 'published' ? threads : []; const threadCardsHTML = filteredPosts.length === 0 && threadsVisible.length === 0 ? `

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

Create Thread Open Campaigns
` : `
${filteredPosts.map(p => { const preview = this.esc(p.content.substring(0, 200)); const schedDate = p.scheduledAt ? new Date(p.scheduledAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : ''; const statusClass = `badge badge--${p.status}`; const statusLabel = p.status.charAt(0).toUpperCase() + p.status.slice(1); const cardClass = `card card--${p.status}`; const tweetCount = p.threadPosts?.length ?? 0; const threadBadge = tweetCount > 1 ? `🧡 ${tweetCount}` : ''; return ``; }).join('')} ${threadsVisible.map(t => { const initial = (t.name || '?').charAt(0).toUpperCase(); const preview = this.esc((t.tweets[0] || '').substring(0, 200)); const dateStr = new Date(t.updatedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); const imageTag = t.imageUrl ? `
` : ''; const href = this._isDemoFallback ? `${this.basePath}thread-editor` : `${this.basePath}thread-editor/${this.esc(t.id)}/edit`; return ` ${imageTag}

${this.esc(t.title || 'Untitled Thread')}

${preview}

${this.esc(initial)}
${this.esc(t.handle || t.name || 'Anonymous')}
${t.tweets.length} tweet${t.tweets.length === 1 ? '' : 's'} ${dateStr}
`; }).join('')}
`; this.shadowRoot.innerHTML = ` ${this.renderOverlay()} `; // Attach chip click handlers after render this.shadowRoot.querySelectorAll('.chip').forEach(btn => { btn.addEventListener('click', () => { const s = (btn as HTMLElement).dataset.status as StatusFilter | undefined; if (!s || s === this._statusFilter) return; this._statusFilter = s; try { localStorage.setItem(STATUS_STORAGE_KEY, s); } catch { /* ignore */ } this.render(); }); }); // Card click β†’ expand this.shadowRoot.querySelectorAll('button.card[data-post-id]').forEach(btn => { btn.addEventListener('click', (e) => { e.preventDefault(); const id = (btn as HTMLElement).dataset.postId; if (id) this.expandPost(id, btn as HTMLElement); }); }); // Overlay wiring this.wireOverlay(); } // ── Overlay rendering ── private renderOverlay(): string { if (!this._expandedPostId) return ''; const post = this._allPosts.find(p => p.id === this._expandedPostId); if (!post) return ''; const platformLabel = post.platform.charAt(0).toUpperCase() + post.platform.slice(1); const statusLabel = post.status.charAt(0).toUpperCase() + post.status.slice(1); const schedDate = post.scheduledAt ? new Date(post.scheduledAt).toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' }) : 'β€”'; const header = `
${this.platformIcon(post.platform)}
${this.esc(platformLabel)} Post
${statusLabel} ${this.esc(post.campaignTitle)}
`; let body = ''; let footer = ''; const limit = PLATFORM_LIMITS[post.platform.toLowerCase()] ?? null; if (this._editMode && this._editDraft) { const d = this._editDraft; const dtLocal = d.scheduledAt ? toDatetimeLocal(d.scheduledAt) : ''; const tweetChainHtml = d.tweets.map((t, i) => this.renderEditTweet(t, i, d.tweets.length, limit, post.platform)).join(''); body = `
${tweetChainHtml}
`; footer = ``; } else { const viewTweets = post.threadPosts && post.threadPosts.length > 0 ? post.threadPosts : [post.content || '']; const tweetChainView = viewTweets.map((t, i) => this.renderViewTweet(t, i, viewTweets.length, limit, post.platform)).join(''); const isThread = viewTweets.length > 1; body = `
${tweetChainView}
Schedule
${this.esc(schedDate)}
Hashtags
${post.hashtags.length ? post.hashtags.map(h => `${this.esc(h)}`).join('') : 'β€”'}
Campaign
${this.esc(post.campaignTitle)}
${isThread ? 'Tweets' : 'Characters'}
${isThread ? `${viewTweets.length} tweets Β· ${viewTweets.reduce((s, t) => s + t.length, 0)} chars total` : String(viewTweets[0].length)}
`; const openHref = post.threadId ? `${this.basePath}thread-editor/${this.esc(post.threadId)}/edit` : `${this.basePath}campaign`; footer = ``; } return `
`; } private renderViewTweet(content: string, idx: number, total: number, limit: number | null, platform: string): string { const charsLeft = limit !== null ? limit - content.length : null; const overLimit = charsLeft !== null && charsLeft < 0; const empty = content.trim().length === 0; return `
${this.platformIcon(platform)}
${idx < total - 1 ? '
' : ''}
${total > 1 ? `${idx + 1}/${total}` : 'Tweet'} ${limit !== null ? `${charsLeft}` : ''}
${empty ? 'Empty' : this.esc(content)}
`; } private renderEditTweet(content: string, idx: number, total: number, limit: number | null, platform: string): string { const charsLeft = limit !== null ? limit - content.length : null; const overLimit = charsLeft !== null && charsLeft < 0; return `
${this.platformIcon(platform)}
${idx < total - 1 ? '
' : ''}
${total > 1 ? `${idx + 1}/${total}` : 'Tweet'} ${limit !== null ? `${charsLeft}` : ''} ${total > 1 ? `` : ''}
`; } private wireOverlay(): void { if (!this.shadowRoot) return; const backdrop = this.shadowRoot.querySelector('.overlay-backdrop') as HTMLElement | null; if (!backdrop) return; backdrop.addEventListener('click', (e) => { const target = e.target as HTMLElement; if (target.dataset.action === 'backdrop') { if (this._editMode) this.exitEditMode(); else this.closeOverlay(); return; } const actionEl = target.closest('[data-action]'); if (!actionEl) return; const action = actionEl.dataset.action; if (action === 'close') this.closeOverlay(); else if (action === 'edit') this.enterEditMode(); else if (action === 'cancel-edit') this.exitEditMode(); else if (action === 'save') this.saveEdit(); else if (action === 'add-tweet') this.addTweet(); else if (action === 'remove-tweet') { const idx = parseInt(actionEl.dataset.idx || '', 10); if (!isNaN(idx)) this.removeTweet(idx); } }); // Bind input listeners in edit mode so the draft stays fresh if (this._editMode) { // Meta fields (status/sched/hashtags) backdrop.querySelectorAll('[data-field]').forEach(el => { el.addEventListener('input', (e) => { const input = e.currentTarget as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement; const field = input.dataset.field; if (!field || !this._editDraft) return; (this._editDraft as any)[field] = input.value; }); }); // Tweet textareas β€” update draft + live character count without full re-render backdrop.querySelectorAll('textarea[data-tweet-idx]').forEach(ta => { ta.addEventListener('input', () => { const idx = parseInt(ta.dataset.tweetIdx || '', 10); if (isNaN(idx) || !this._editDraft) return; this._editDraft.tweets[idx] = ta.value; this.updateTweetCounter(ta, ta.value); }); }); } // FLIP animation entry β€” only on first render per open if (!this._flipPlayed) { this._flipPlayed = true; const card = backdrop.querySelector('#overlay-card') as HTMLElement | null; const origin = this._flipOrigin; if (card && origin) { const target = card.getBoundingClientRect(); const dx = origin.left + origin.width / 2 - (target.left + target.width / 2); const dy = origin.top + origin.height / 2 - (target.top + target.height / 2); const sx = origin.width / target.width; const sy = origin.height / target.height; card.animate([ { transform: `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`, opacity: 0.6 }, { transform: 'translate(0, 0) scale(1, 1)', opacity: 1 }, ], { duration: 220, easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)' }); } } } // ── Overlay state transitions ── private _flipOrigin: DOMRect | null = null; private _flipPlayed = false; private expandPost(id: string, sourceEl?: HTMLElement): void { this._flipOrigin = sourceEl?.getBoundingClientRect() ?? null; this._flipPlayed = false; this._expandedPostId = id; this._editMode = false; this._editDraft = null; this._savingIndicator = ''; this.render(); } private closeOverlay(): void { this._expandedPostId = null; this._editMode = false; this._editDraft = null; this._flipOrigin = null; this._flipPlayed = false; this.render(); } private enterEditMode(): void { if (!this._expandedPostId) return; if (this._isDemoFallback) return; // no runtime = read-only const post = this._allPosts.find(p => p.id === this._expandedPostId); if (!post) return; // Seed tweets from post.threadPosts if present, else single-tweet array from content. const tweets = post.threadPosts && post.threadPosts.length > 0 ? [...post.threadPosts] : [post.content || '']; this._editDraft = { tweets, scheduledAt: post.scheduledAt, hashtags: (post.hashtags || []).join(' '), status: post.status as 'draft' | 'scheduled' | 'published', }; this._editMode = true; this.render(); // Focus first tweet requestAnimationFrame(() => { const ta = this.shadowRoot?.querySelector('[data-tweet-idx="0"]') as HTMLTextAreaElement | null; ta?.focus(); }); } private exitEditMode(): void { this._editMode = false; this._editDraft = null; this._savingIndicator = ''; this.render(); } private addTweet(): void { if (!this._editDraft) return; this._editDraft.tweets.push(''); this.render(); // Focus the new tweet requestAnimationFrame(() => { const tweets = this.shadowRoot?.querySelectorAll('textarea[data-tweet-idx]'); tweets?.[tweets.length - 1]?.focus(); }); } private removeTweet(idx: number): void { if (!this._editDraft || this._editDraft.tweets.length <= 1) return; this._editDraft.tweets.splice(idx, 1); this.render(); } private updateTweetCounter(ta: HTMLTextAreaElement, value: string): void { const post = this._allPosts.find(p => p.id === this._expandedPostId); if (!post) return; const limit = PLATFORM_LIMITS[post.platform.toLowerCase()] ?? null; if (limit === null) return; const row = ta.closest('.tweet-row'); const counter = row?.querySelector('.tweet-count') as HTMLElement | null; if (!counter) return; const left = limit - value.length; counter.textContent = String(left); counter.classList.toggle('tweet-count--over', left < 0); } private async saveEdit(): Promise { if (!this._editDraft || !this._expandedPostId) return; const runtime = (window as any).__rspaceOfflineRuntime; if (!runtime?.isInitialized) { this._savingIndicator = 'Offline runtime unavailable'; this.render(); return; } const draft = this._editDraft; const postId = this._expandedPostId; const hashtags = draft.hashtags .split(/[\s,]+/) .map(s => s.trim()) .filter(Boolean) .map(s => s.startsWith('#') ? s : `#${s}`); // datetime-local string β†’ ISO const scheduledAtIso = draft.scheduledAt ? new Date(draft.scheduledAt).toISOString() : ''; this._savingIndicator = 'Saving…'; this.render(); try { const docId = socialsDocId(this._space) as DocumentId; // Normalize: strip empty trailing tweets; keep at least one. const tweets = draft.tweets.map(t => t.trim()).filter((t, i, arr) => t.length > 0 || i === 0); if (tweets.length === 0) tweets.push(''); const isThread = tweets.length > 1; await runtime.changeDoc(docId, 'edit post', (d: SocialsDoc) => { if (!d.campaigns) return; for (const campaign of Object.values(d.campaigns)) { const post = (campaign.posts || []).find((p: CampaignPost) => p.id === postId); if (post) { // Single-tweet β†’ use content; thread β†’ use threadPosts (keep content in sync w/ tweet 1) post.content = tweets[0]; post.threadPosts = isThread ? tweets : undefined; post.status = draft.status; post.scheduledAt = scheduledAtIso; post.hashtags = hashtags; return; } } }); this._savingIndicator = 'Saved'; this._editMode = false; this._editDraft = null; this.render(); setTimeout(() => { this._savingIndicator = ''; this.render(); }, 1500); } catch (err) { console.warn('[folk-thread-gallery] save failed', err); this._savingIndicator = 'Save failed'; this.render(); } } } function toDatetimeLocal(iso: string): string { try { const d = new Date(iso); if (isNaN(d.getTime())) return ''; const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; } catch { return ''; } } customElements.define('folk-thread-gallery', FolkThreadGallery);