diff --git a/modules/rsocials/components/folk-thread-gallery.ts b/modules/rsocials/components/folk-thread-gallery.ts index e78fc9f9..a4cb1b2e 100644 --- a/modules/rsocials/components/folk-thread-gallery.ts +++ b/modules/rsocials/components/folk-thread-gallery.ts @@ -20,18 +20,25 @@ interface DraftPostCard { status: string; hashtags: string[]; threadId?: string; + threadPosts?: string[]; } type StatusFilter = 'draft' | 'scheduled' | 'published'; const STATUS_STORAGE_KEY = 'rsocials:gallery:status-filter'; interface EditDraft { - content: string; + 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[] = []; @@ -152,6 +159,7 @@ export class FolkThreadGallery extends HTMLElement { status: post.status, hashtags: post.hashtags || [], threadId: post.threadId, + threadPosts: post.threadPosts ? [...post.threadPosts] : undefined, }); } } @@ -230,10 +238,13 @@ export class FolkThreadGallery extends HTMLElement { 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 ` + -
- - -
-
- - +
+
+ + +
+
+ + +
@@ -563,8 +674,16 @@ export class FolkThreadGallery extends HTMLElement {
`; } 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 = `
-
${this.esc(post.content) || 'Empty post'}
+
+ ${tweetChainView} +
Schedule
${this.esc(schedDate)}
@@ -572,8 +691,8 @@ export class FolkThreadGallery extends HTMLElement {
${post.hashtags.length ? post.hashtags.map(h => `${this.esc(h)}`).join('') : 'โ€”'}
Campaign
${this.esc(post.campaignTitle)}
-
Characters
-
${post.content.length}
+
${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 @@ -594,6 +713,44 @@ export class FolkThreadGallery extends HTMLElement {
`; } + 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; @@ -613,10 +770,16 @@ export class FolkThreadGallery extends HTMLElement { 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; @@ -625,14 +788,23 @@ export class FolkThreadGallery extends HTMLElement { (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 mount of this overlay) - const card = backdrop.querySelector('#overlay-card') as HTMLElement | null; - if (card && !card.dataset.animated) { - card.dataset.animated = '1'; + // 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 (origin) { + 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); @@ -649,9 +821,11 @@ export class FolkThreadGallery extends HTMLElement { // โ”€โ”€ 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; @@ -664,6 +838,7 @@ export class FolkThreadGallery extends HTMLElement { this._editMode = false; this._editDraft = null; this._flipOrigin = null; + this._flipPlayed = false; this.render(); } @@ -672,17 +847,21 @@ export class FolkThreadGallery extends HTMLElement { 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 = { - content: post.content, + tweets, scheduledAt: post.scheduledAt, hashtags: (post.hashtags || []).join(' '), status: post.status as 'draft' | 'scheduled' | 'published', }; this._editMode = true; this.render(); - // Focus content textarea + // Focus first tweet requestAnimationFrame(() => { - const ta = this.shadowRoot?.getElementById('f-content') as HTMLTextAreaElement | null; + const ta = this.shadowRoot?.querySelector('[data-tweet-idx="0"]') as HTMLTextAreaElement | null; ta?.focus(); }); } @@ -694,6 +873,36 @@ export class FolkThreadGallery extends HTMLElement { 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; @@ -717,12 +926,19 @@ export class FolkThreadGallery extends HTMLElement { 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) { - post.content = draft.content; + // 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; diff --git a/modules/rsocials/mod.ts b/modules/rsocials/mod.ts index 87f9d967..a0321e62 100644 --- a/modules/rsocials/mod.ts +++ b/modules/rsocials/mod.ts @@ -3026,7 +3026,7 @@ routes.get("/threads", (c) => { theme: "dark", body: ``, styles: ``, - scripts: ``, + scripts: ``, })); }); @@ -3181,7 +3181,7 @@ routes.get("/", (c) => { theme: "dark", styles: ``, body: ``, - scripts: ``, + scripts: ``, })); });