/** * — Thread compose/preview/readonly web component. * * Attributes: * space — space slug * thread-id — existing thread ID (for edit/readonly) * mode — "new" | "edit" | "readonly" * * In new/edit mode: compose pane + live preview, auto-save to Automerge, * draft management, image ops, export to multiple platforms. * * In readonly mode: display thread with share/copy/export actions. */ import { socialsSchema, socialsDocId } from '../schemas'; import type { SocialsDoc, ThreadData } from '../schemas'; import type { DocumentId } from '../../../shared/local-first/document'; import { PLATFORM_LIMITS } from '../lib/types'; function generateThreadId(): string { const random = Math.random().toString(36).substring(2, 8); return `t-${Date.now()}-${random}`; } export class FolkThreadBuilder extends HTMLElement { private _space = 'demo'; private _threadId: string | null = null; private _mode: 'new' | 'edit' | 'readonly' = 'new'; private _thread: ThreadData | null = null; private _tweetImages: Record = {}; private _autoSaveTimer: ReturnType | null = null; private _offlineUnsub: (() => void) | null = null; private _tweetImageUploadIdx: string | null = null; // SVG icons private svgReply = ''; private svgRetweet = ''; private svgHeart = ''; private svgShare = ''; private svgUpload = ''; private svgSparkle = ''; private svgCamera = ''; static get observedAttributes() { return ['space', 'thread-id', 'mode']; } connectedCallback() { this.attachShadow({ mode: 'open' }); this._space = this.getAttribute('space') || 'demo'; this._threadId = this.getAttribute('thread-id') || null; this._mode = (this.getAttribute('mode') as any) || 'new'; // Check for server-hydrated data if ((window as any).__THREAD_DATA__) { const data = (window as any).__THREAD_DATA__; this._thread = data; this._threadId = data.id; this._tweetImages = data.tweetImages || {}; } this.render(); if (this._space !== 'demo') { this.subscribeOffline(); } } disconnectedCallback() { this._offlineUnsub?.(); this._offlineUnsub = null; if (this._autoSaveTimer) clearTimeout(this._autoSaveTimer); } attributeChangedCallback(name: string, _old: string, val: string) { if (name === 'space') this._space = val; else if (name === 'thread-id') this._threadId = val; else if (name === 'mode') this._mode = val as any; } private get basePath() { return `/${this._space}/rsocials/`; } private async subscribeOffline() { const runtime = (window as any).__rspaceOfflineRuntime; if (!runtime?.isInitialized) return; try { const docId = socialsDocId(this._space) as DocumentId; const doc = await runtime.subscribe(docId, socialsSchema); if (this._threadId && doc?.threads?.[this._threadId] && !this._thread) { this._thread = doc.threads[this._threadId]; this._tweetImages = this._thread?.tweetImages || {}; this.render(); } this._offlineUnsub = runtime.onChange(docId, (updated: SocialsDoc) => { if (this._threadId && updated?.threads?.[this._threadId]) { this._thread = updated.threads[this._threadId]; } }); } catch { // Working without offline runtime } } private esc(s: string): string { return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } private getRuntime() { return (window as any).__rspaceOfflineRuntime; } private saveToAutomerge(thread: ThreadData) { const runtime = this.getRuntime(); if (!runtime?.isInitialized) return; const docId = socialsDocId(this._space) as DocumentId; runtime.change(docId, `Save thread ${thread.title || thread.id}`, (d: SocialsDoc) => { if (!d.threads) d.threads = {} as any; thread.updatedAt = Date.now(); d.threads[thread.id] = thread; }); } private deleteFromAutomerge(id: string) { const runtime = this.getRuntime(); if (!runtime?.isInitialized) return; const docId = socialsDocId(this._space) as DocumentId; runtime.change(docId, `Delete thread ${id}`, (d: SocialsDoc) => { if (d.threads?.[id]) delete d.threads[id]; }); } private getDoc(): SocialsDoc | undefined { const runtime = this.getRuntime(); if (!runtime?.isInitialized) return undefined; const docId = socialsDocId(this._space) as DocumentId; return runtime.getDoc(docId); } private listThreads(): ThreadData[] { const doc = this.getDoc(); if (!doc?.threads) return []; return Object.values(doc.threads).sort((a: ThreadData, b: ThreadData) => b.updatedAt - a.updatedAt); } // ── Rendering ── private render() { if (!this.shadowRoot) return; if (this._mode === 'readonly') { this.renderReadonly(); } else { this.renderEditor(); } } private renderReadonly() { if (!this.shadowRoot || !this._thread) return; const t = this._thread; const name = this.esc(t.name || 'Anonymous'); const handle = this.esc(t.handle || '@anonymous'); const initial = name.charAt(0).toUpperCase(); const total = t.tweets.length; const dateStr = new Date(t.createdAt).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }); const tweetCards = t.tweets.map((text, i) => { const len = text.length; const connector = i > 0 ? '
' : ''; const tweetImgUrl = t.tweetImages?.[String(i)]; const tweetImgHtml = tweetImgUrl ? `
Tweet image
` : ''; return `
${connector}
${this.esc(initial)}
${name} ${handle} · ${this.esc(dateStr)}

${this.esc(text)}

${tweetImgHtml}
`; }).join('\n'); const imageHTML = t.imageUrl ? `
Thread preview
` : ''; this.shadowRoot.innerHTML = `
${this.esc(initial)}
${name}
${handle}
${total} tweet${total === 1 ? '' : 's'} · ${this.esc(dateStr)}
${t.title ? `

${this.esc(t.title)}

` : ''} ${imageHTML}
${tweetCards}
Edit Thread
`; this.bindReadonlyEvents(); } private renderEditor() { if (!this.shadowRoot) return; const t = this._thread; this.shadowRoot.innerHTML = `
Preview
Your tweet thread preview will appear here
`; if (t) { this._tweetImages = t.tweetImages || {}; } this.bindEditorEvents(); if (t) this.renderPreview(); } // ── Preview rendering ── private renderPreview() { const sr = this.shadowRoot; if (!sr) return; const textarea = sr.getElementById('thread-input') as HTMLTextAreaElement; const preview = sr.getElementById('thread-preview'); const nameInput = sr.getElementById('thread-name') as HTMLInputElement; const handleInput = sr.getElementById('thread-handle') as HTMLInputElement; if (!textarea || !preview || !nameInput || !handleInput) return; const raw = textarea.value; const tweets = raw.split(/\n---\n/).map(t => t.trim()).filter(Boolean); const name = nameInput.value || 'Your Name'; const handle = handleInput.value || '@yourhandle'; const initial = name.charAt(0).toUpperCase(); const total = tweets.length; if (!total) { preview.innerHTML = '
Your tweet thread preview will appear here
'; return; } preview.innerHTML = tweets.map((text, i) => { const len = text.length; const overClass = len > 280 ? ' tc-chars--over' : ''; const connector = i > 0 ? '
' : ''; const imgUrl = this._tweetImages[String(i)]; const imgHtml = imgUrl ? `
Tweet image
` : ''; const photoBtn = !imgUrl ? ` ` : ''; return `
${connector} ${photoBtn}
${this.esc(initial)}
${this.esc(name)} ${this.esc(handle)} · now

${this.esc(text)}

${imgHtml}
`; }).join(''); } // ── Draft management (Automerge) ── private saveDraft() { const sr = this.shadowRoot; if (!sr) return; const textarea = sr.getElementById('thread-input') as HTMLTextAreaElement; const nameInput = sr.getElementById('thread-name') as HTMLInputElement; const handleInput = sr.getElementById('thread-handle') as HTMLInputElement; const titleInput = sr.getElementById('thread-title') as HTMLInputElement; const saveBtn = sr.getElementById('thread-save') as HTMLButtonElement; const tweets = textarea.value.split(/\n---\n/).map(t => t.trim()).filter(Boolean); if (!tweets.length) return; if (!this._threadId) { this._threadId = generateThreadId(); } const thread: ThreadData = { id: this._threadId, name: nameInput.value || 'Your Name', handle: handleInput.value || '@yourhandle', title: titleInput.value || tweets[0].substring(0, 60), tweets, tweetImages: Object.keys(this._tweetImages).length ? { ...this._tweetImages } : undefined, imageUrl: this._thread?.imageUrl, createdAt: this._thread?.createdAt || Date.now(), updatedAt: Date.now(), }; this._thread = thread; // Save via Automerge this.saveToAutomerge(thread); // Update URL history.replaceState(null, '', this.basePath + 'thread/' + this._threadId + '/edit'); if (saveBtn) { saveBtn.textContent = 'Saved!'; setTimeout(() => { saveBtn.textContent = 'Save Draft'; }, 2000); } this.loadDraftList(); } private loadDraft(id: string) { const doc = this.getDoc(); const thread = doc?.threads?.[id]; if (!thread) return; this._threadId = thread.id; this._thread = thread; this._tweetImages = thread.tweetImages || {}; const sr = this.shadowRoot; if (!sr) return; const textarea = sr.getElementById('thread-input') as HTMLTextAreaElement; const nameInput = sr.getElementById('thread-name') as HTMLInputElement; const handleInput = sr.getElementById('thread-handle') as HTMLInputElement; const titleInput = sr.getElementById('thread-title') as HTMLInputElement; const imagePreview = sr.getElementById('thread-image-preview') as HTMLElement; const imageThumb = sr.getElementById('thread-image-thumb') as HTMLImageElement; const genBtn = sr.getElementById('gen-image-btn') as HTMLButtonElement; const uploadBtn = sr.getElementById('upload-image-btn') as HTMLButtonElement; textarea.value = thread.tweets.join('\n---\n'); nameInput.value = thread.name || ''; handleInput.value = thread.handle || ''; titleInput.value = thread.title || ''; if (thread.imageUrl) { imageThumb.src = thread.imageUrl; imagePreview.hidden = false; genBtn.textContent = 'Replace with AI'; uploadBtn.textContent = 'Replace Image'; } else { imagePreview.hidden = true; genBtn.textContent = 'Generate with AI'; uploadBtn.textContent = 'Upload Image'; } history.replaceState(null, '', this.basePath + 'thread/' + thread.id + '/edit'); this.renderPreview(); this.loadDraftList(); } private async deleteDraft(id: string) { if (!confirm('Delete this draft?')) return; // Delete images on server try { await fetch(this.basePath + 'api/threads/' + id + '/images', { method: 'DELETE' }); } catch { /* ignore */ } // Remove from Automerge this.deleteFromAutomerge(id); if (this._threadId === id) { this._threadId = null; this._thread = null; this._tweetImages = {}; history.replaceState(null, '', this.basePath + 'thread'); const sr = this.shadowRoot; if (sr) { const imagePreview = sr.getElementById('thread-image-preview') as HTMLElement; const genBtn = sr.getElementById('gen-image-btn') as HTMLButtonElement; const uploadBtn = sr.getElementById('upload-image-btn') as HTMLButtonElement; const shareLinkArea = sr.getElementById('share-link-area') as HTMLElement; if (imagePreview) imagePreview.hidden = true; if (genBtn) genBtn.textContent = 'Generate with AI'; if (uploadBtn) uploadBtn.textContent = 'Upload Image'; if (shareLinkArea) shareLinkArea.innerHTML = ''; } } this.loadDraftList(); } private loadDraftList() { const sr = this.shadowRoot; if (!sr) return; const draftsList = sr.getElementById('drafts-list'); if (!draftsList) return; const threads = this.listThreads(); if (!threads.length) { draftsList.innerHTML = '
No saved drafts
'; return; } draftsList.innerHTML = threads.map(t => { const date = new Date(t.updatedAt); const dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); const active = t.id === this._threadId ? ' draft-item--active' : ''; return `
${this.esc(t.title || 'Untitled')} ${t.tweets.length} tweets · ${dateStr}
`; }).join(''); // Attach events draftsList.querySelectorAll('[data-load-id]').forEach(el => { el.addEventListener('click', () => this.loadDraft((el as HTMLElement).dataset.loadId!)); }); draftsList.querySelectorAll('[data-delete-id]').forEach(el => { el.addEventListener('click', (e) => { e.stopPropagation(); this.deleteDraft((el as HTMLElement).dataset.deleteId!); }); }); } // ── Image operations (server API) ── private async generateImage() { if (!this._threadId) { this.saveDraft(); if (!this._threadId) return; } const sr = this.shadowRoot; if (!sr) return; const genBtn = sr.getElementById('gen-image-btn') as HTMLButtonElement; const imagePreview = sr.getElementById('thread-image-preview') as HTMLElement; const imageThumb = sr.getElementById('thread-image-thumb') as HTMLImageElement; const uploadBtn = sr.getElementById('upload-image-btn') as HTMLButtonElement; genBtn.textContent = 'Generating...'; genBtn.disabled = true; try { const res = await fetch(this.basePath + 'api/threads/' + this._threadId + '/image', { method: 'POST' }); const data = await res.json(); if (data.imageUrl) { imageThumb.src = data.imageUrl; imagePreview.hidden = false; genBtn.textContent = 'Replace with AI'; uploadBtn.textContent = 'Replace Image'; // Update Automerge with new image URL if (this._thread) { this._thread.imageUrl = data.imageUrl; this.saveToAutomerge(this._thread); } } else { genBtn.textContent = 'Generation Failed'; setTimeout(() => { genBtn.textContent = imagePreview.hidden ? 'Generate with AI' : 'Replace with AI'; }, 2000); } } catch { genBtn.textContent = 'Generation Failed'; setTimeout(() => { genBtn.textContent = imagePreview.hidden ? 'Generate with AI' : 'Replace with AI'; }, 2000); } finally { genBtn.disabled = false; } } private async uploadImage(file: File) { if (!this._threadId) { this.saveDraft(); if (!this._threadId) return; } const sr = this.shadowRoot; if (!sr) return; const uploadBtn = sr.getElementById('upload-image-btn') as HTMLButtonElement; const imagePreview = sr.getElementById('thread-image-preview') as HTMLElement; const imageThumb = sr.getElementById('thread-image-thumb') as HTMLImageElement; const genBtn = sr.getElementById('gen-image-btn') as HTMLButtonElement; uploadBtn.textContent = 'Uploading...'; uploadBtn.disabled = true; try { const form = new FormData(); form.append('file', file); const res = await fetch(this.basePath + 'api/threads/' + this._threadId + '/upload-image', { method: 'POST', body: form }); const data = await res.json(); if (data.imageUrl) { imageThumb.src = data.imageUrl; imagePreview.hidden = false; uploadBtn.textContent = 'Replace Image'; genBtn.textContent = 'Replace with AI'; if (this._thread) { this._thread.imageUrl = data.imageUrl; this.saveToAutomerge(this._thread); } } else { uploadBtn.textContent = data.error || 'Upload Failed'; setTimeout(() => { uploadBtn.textContent = imagePreview.hidden ? 'Upload Image' : 'Replace Image'; }, 2000); } } catch { uploadBtn.textContent = 'Upload Failed'; setTimeout(() => { uploadBtn.textContent = imagePreview.hidden ? 'Upload Image' : 'Replace Image'; }, 2000); } finally { uploadBtn.disabled = false; } } private async uploadTweetImage(index: string, file: File) { if (!this._threadId) { this.saveDraft(); if (!this._threadId) return; } try { const form = new FormData(); form.append('file', file); const res = await fetch(this.basePath + 'api/threads/' + this._threadId + '/tweet/' + index + '/upload-image', { method: 'POST', body: form }); const data = await res.json(); if (data.imageUrl) { this._tweetImages[index] = data.imageUrl; if (this._thread) { this._thread.tweetImages = { ...this._tweetImages }; this.saveToAutomerge(this._thread); } this.renderPreview(); } } catch (e) { console.error('Tweet image upload failed:', e); } } private async generateTweetImage(index: string) { if (!this._threadId) { this.saveDraft(); if (!this._threadId) return; } const sr = this.shadowRoot; const btn = sr?.querySelector(`[data-generate-idx="${index}"]`) as HTMLButtonElement; if (btn) { btn.textContent = 'Generating...'; btn.disabled = true; } try { const res = await fetch(this.basePath + 'api/threads/' + this._threadId + '/tweet/' + index + '/image', { method: 'POST' }); const data = await res.json(); if (data.imageUrl) { this._tweetImages[index] = data.imageUrl; if (this._thread) { this._thread.tweetImages = { ...this._tweetImages }; this.saveToAutomerge(this._thread); } this.renderPreview(); } else if (btn) { btn.textContent = 'Failed'; setTimeout(() => this.renderPreview(), 2000); } } catch { if (btn) { btn.textContent = 'Failed'; setTimeout(() => this.renderPreview(), 2000); } } } private async removeTweetImage(index: string) { if (!this._threadId) return; try { await fetch(this.basePath + 'api/threads/' + this._threadId + '/tweet/' + index + '/image', { method: 'DELETE' }); delete this._tweetImages[index]; if (this._thread) { this._thread.tweetImages = Object.keys(this._tweetImages).length ? { ...this._tweetImages } : undefined; this.saveToAutomerge(this._thread); } this.renderPreview(); } catch (e) { console.error('Tweet image removal failed:', e); } } // ── Export ── private formatForPlatform(platform: string): { text: string; warnings: string[] } { const sr = this.shadowRoot; if (!sr) return { text: '', warnings: [] }; const textarea = sr.getElementById('thread-input') as HTMLTextAreaElement; const titleInput = sr.getElementById('thread-title') as HTMLInputElement; const limit = PLATFORM_LIMITS[platform] || 280; const tweets = (this._mode === 'readonly' && this._thread) ? this._thread.tweets : textarea.value.split(/\n---\n/).map(t => t.trim()).filter(Boolean); const total = tweets.length; const title = (this._mode === 'readonly' && this._thread) ? this._thread.title : titleInput?.value || ''; const warnings: string[] = []; if (platform === 'linkedin') { let text = ''; if (title) text += title + '\n\n'; text += tweets.join('\n\n'); text += '\n\n---\nOriginally composed as a ' + total + '-tweet thread.'; return { text, warnings: text.length > limit ? ['Content exceeds LinkedIn\'s ' + limit + ' char limit (' + text.length + ' chars)'] : [] }; } const parts = tweets.map((t, i) => { const prefix = total > 1 ? (i + 1) + '/' + total + ' ' : ''; const full = prefix + t; if (full.length > limit) warnings.push('Tweet ' + (i + 1) + ' exceeds ' + limit + ' chars (' + full.length + ')'); return full; }); return { text: parts.join('\n\n'), warnings }; } // ── Auto-save ── private scheduleAutoSave() { if (!this._threadId) return; if (this._autoSaveTimer) clearTimeout(this._autoSaveTimer); this._autoSaveTimer = setTimeout(() => this.saveDraft(), 1500); } // ── Event binding ── private bindEditorEvents() { const sr = this.shadowRoot; if (!sr) return; const textarea = sr.getElementById('thread-input') as HTMLTextAreaElement; const nameInput = sr.getElementById('thread-name') as HTMLInputElement; const handleInput = sr.getElementById('thread-handle') as HTMLInputElement; const titleInput = sr.getElementById('thread-title') as HTMLInputElement; const saveBtn = sr.getElementById('thread-save'); const shareBtn = sr.getElementById('thread-share'); const copyBtn = sr.getElementById('thread-copy'); const genImageBtn = sr.getElementById('gen-image-btn'); const uploadImageBtn = sr.getElementById('upload-image-btn'); const uploadImageInput = sr.getElementById('upload-image-input') as HTMLInputElement; const toggleDraftsBtn = sr.getElementById('toggle-drafts'); const draftsList = sr.getElementById('drafts-list') as HTMLElement; const preview = sr.getElementById('thread-preview') as HTMLElement; const tweetImageInput = sr.getElementById('tweet-image-input') as HTMLInputElement; // Preview updates textarea?.addEventListener('input', () => this.renderPreview()); nameInput?.addEventListener('input', () => this.renderPreview()); handleInput?.addEventListener('input', () => this.renderPreview()); // Auto-save on blur textarea?.addEventListener('blur', () => this.scheduleAutoSave()); nameInput?.addEventListener('blur', () => this.scheduleAutoSave()); handleInput?.addEventListener('blur', () => this.scheduleAutoSave()); titleInput?.addEventListener('blur', () => this.scheduleAutoSave()); // Buttons saveBtn?.addEventListener('click', () => this.saveDraft()); shareBtn?.addEventListener('click', () => this.shareThread()); genImageBtn?.addEventListener('click', () => this.generateImage()); uploadImageBtn?.addEventListener('click', () => uploadImageInput?.click()); uploadImageInput?.addEventListener('change', () => { const file = uploadImageInput.files?.[0]; if (file) this.uploadImage(file); uploadImageInput.value = ''; }); // Drafts toggle toggleDraftsBtn?.addEventListener('click', () => { draftsList.hidden = !draftsList.hidden; toggleDraftsBtn.innerHTML = draftsList.hidden ? 'Saved Drafts ▾' : 'Saved Drafts ▴'; }); // Copy thread copyBtn?.addEventListener('click', async () => { const tweets = textarea.value.split(/\n---\n/).map(t => t.trim()).filter(Boolean); if (!tweets.length) return; const total = tweets.length; const text = tweets.map((t, i) => (i + 1) + '/' + total + '\n' + t).join('\n\n'); try { await navigator.clipboard.writeText(text); copyBtn.textContent = 'Copied!'; setTimeout(() => { copyBtn.textContent = 'Copy Thread'; }, 2000); } catch { copyBtn.textContent = 'Failed'; setTimeout(() => { copyBtn.textContent = 'Copy Thread'; }, 2000); } }); // Export dropdown const exportBtn = sr.getElementById('thread-export-btn'); const exportMenu = sr.getElementById('thread-export-menu'); exportBtn?.addEventListener('click', () => { if (exportMenu) exportMenu.hidden = !exportMenu.hidden; }); sr.addEventListener('click', (e) => { if (!exportBtn?.contains(e.target as Node) && !exportMenu?.contains(e.target as Node)) { if (exportMenu) exportMenu.hidden = true; } }); exportMenu?.querySelectorAll('button[data-platform]').forEach(btn => { btn.addEventListener('click', async () => { const platform = (btn as HTMLElement).dataset.platform!; const tweets = textarea.value.split(/\n---\n/).map(t => t.trim()).filter(Boolean); if (!tweets.length) return; const { text, warnings } = this.formatForPlatform(platform); try { await navigator.clipboard.writeText(text); const label = warnings.length ? 'Copied with warnings: ' + warnings.join('; ') : 'Copied for ' + platform + '!'; if (copyBtn) { copyBtn.textContent = label; setTimeout(() => { copyBtn.textContent = 'Copy Thread'; }, 3000); } } catch { if (copyBtn) { copyBtn.textContent = 'Failed'; setTimeout(() => { copyBtn.textContent = 'Copy Thread'; }, 2000); } } if (exportMenu) exportMenu.hidden = true; }); }); // Per-tweet image operations (event delegation) preview?.addEventListener('click', (e) => { const target = e.target as HTMLElement; const photoBtn = target.closest('[data-photo-idx]') as HTMLElement; if (photoBtn) { const idx = photoBtn.dataset.photoIdx!; const menu = preview.querySelector(`[data-menu-idx="${idx}"]`) as HTMLElement; if (menu) { const wasHidden = menu.hidden; preview.querySelectorAll('.photo-menu').forEach(m => (m as HTMLElement).hidden = true); menu.hidden = !wasHidden; } return; } const uploadTweetBtn = target.closest('[data-upload-idx]') as HTMLElement; if (uploadTweetBtn) { preview.querySelectorAll('.photo-menu').forEach(m => (m as HTMLElement).hidden = true); this._tweetImageUploadIdx = uploadTweetBtn.dataset.uploadIdx!; tweetImageInput?.click(); return; } const genTweetBtn = target.closest('[data-generate-idx]') as HTMLElement; if (genTweetBtn) { preview.querySelectorAll('.photo-menu').forEach(m => (m as HTMLElement).hidden = true); this.generateTweetImage(genTweetBtn.dataset.generateIdx!); return; } const removeBtn = target.closest('[data-remove-idx]') as HTMLElement; if (removeBtn) { this.removeTweetImage(removeBtn.dataset.removeIdx!); return; } }); // Close photo menus on outside click sr.addEventListener('click', (e) => { const target = e.target as HTMLElement; if (!target.closest('.photo-btn') && !target.closest('.photo-menu')) { preview?.querySelectorAll('.photo-menu').forEach(m => (m as HTMLElement).hidden = true); } }); // Tweet image file input tweetImageInput?.addEventListener('change', () => { const file = tweetImageInput.files?.[0]; if (file && this._tweetImageUploadIdx !== null) { this.uploadTweetImage(this._tweetImageUploadIdx, file); } tweetImageInput.value = ''; this._tweetImageUploadIdx = null; }); // Load initial draft list this.loadDraftList(); } private async shareThread() { const sr = this.shadowRoot; if (!sr) return; const shareBtn = sr.getElementById('thread-share') as HTMLButtonElement; const shareLinkArea = sr.getElementById('share-link-area') as HTMLElement; const imagePreview = sr.getElementById('thread-image-preview') as HTMLElement; shareBtn.textContent = 'Saving...'; shareBtn.disabled = true; try { this.saveDraft(); if (!this._threadId) { shareBtn.textContent = 'Share'; shareBtn.disabled = false; return; } if (imagePreview?.hidden) { shareBtn.textContent = 'Generating image...'; await this.generateImage(); } const url = window.location.origin + this.basePath + 'thread/' + this._threadId; try { await navigator.clipboard.writeText(url); shareBtn.textContent = 'Link Copied!'; } catch { shareBtn.textContent = 'Shared!'; } shareLinkArea.innerHTML = ``; sr.getElementById('copy-share-link')?.addEventListener('click', () => { navigator.clipboard.writeText(url); }); } catch { shareBtn.textContent = 'Error'; } setTimeout(() => { shareBtn.textContent = 'Share'; shareBtn.disabled = false; }, 3000); } private bindReadonlyEvents() { const sr = this.shadowRoot; if (!sr || !this._thread) return; const t = this._thread; sr.getElementById('ro-copy-thread')?.addEventListener('click', async () => { const text = t.tweets.map((tw, i) => (i + 1) + '/' + t.tweets.length + '\n' + tw).join('\n\n'); try { await navigator.clipboard.writeText(text); this.showToast('Thread copied!'); } catch { this.showToast('Failed to copy'); } }); sr.getElementById('ro-copy-link')?.addEventListener('click', async () => { try { await navigator.clipboard.writeText(window.location.href); this.showToast('Link copied!'); } catch { this.showToast('Failed to copy'); } }); const exportBtn = sr.getElementById('ro-export-btn'); const exportMenu = sr.getElementById('ro-export-menu'); exportBtn?.addEventListener('click', () => { if (exportMenu) exportMenu.hidden = !exportMenu.hidden; }); sr.addEventListener('click', (e) => { if (!exportBtn?.contains(e.target as Node) && !exportMenu?.contains(e.target as Node)) { if (exportMenu) exportMenu.hidden = true; } }); exportMenu?.querySelectorAll('button[data-platform]').forEach(btn => { btn.addEventListener('click', async () => { const platform = (btn as HTMLElement).dataset.platform!; const { text, warnings } = this.formatForPlatform(platform); try { await navigator.clipboard.writeText(text); this.showToast(warnings.length ? 'Copied with warnings: ' + warnings.join('; ') : 'Copied for ' + platform + '!'); } catch { this.showToast('Failed to copy'); } if (exportMenu) exportMenu.hidden = true; }); }); } private showToast(msg: string) { const toast = this.shadowRoot?.getElementById('export-toast'); if (!toast) return; toast.textContent = msg; toast.hidden = false; setTimeout(() => { toast.hidden = true; }, 2500); } // ── Styles ── private getBaseStyles(): string { return ` :host { display: block; } .btn { padding: 0.5rem 1rem; border-radius: 8px; border: none; font-size: 0.85rem; font-weight: 600; cursor: pointer; transition: all 0.15s; text-decoration: none; display: inline-flex; align-items: center; } .btn--primary { background: #6366f1; color: white; } .btn--primary:hover { background: #818cf8; } .btn--outline { background: transparent; color: var(--rs-text-secondary, #94a3b8); border: 1px solid var(--rs-input-border, #334155); } .btn--outline:hover { border-color: #6366f1; color: #c4b5fd; } .btn--success { background: #10b981; color: white; } .btn--success:hover { background: #34d399; } .btn:disabled { opacity: 0.5; cursor: not-allowed; } .tweet-card { position: relative; background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-input-border, #334155); border-radius: 0.75rem; padding: 1rem; margin-bottom: 0; } .tweet-card + .tweet-card { border-top-left-radius: 0; border-top-right-radius: 0; margin-top: -1px; } .tweet-card:has(+ .tweet-card) { border-bottom-left-radius: 0; border-bottom-right-radius: 0; } .connector { position: absolute; left: 29px; top: -1px; width: 2px; height: 1rem; background: var(--rs-input-border, #334155); z-index: 1; } .tc-header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; } .tc-avatar { width: 40px; height: 40px; border-radius: 50%; background: #6366f1; display: flex; align-items: center; justify-content: center; color: white; font-weight: 700; font-size: 1rem; flex-shrink: 0; } .tc-name { font-weight: 700; color: var(--rs-text-primary, #f1f5f9); font-size: 0.9rem; } .tc-handle { color: var(--rs-text-muted, #64748b); font-size: 0.85rem; } .tc-dot { color: var(--rs-text-muted, #64748b); font-size: 0.85rem; } .tc-time { color: var(--rs-text-muted, #64748b); font-size: 0.85rem; } .tc-content { color: var(--rs-text-primary, #f1f5f9); font-size: 0.95rem; line-height: 1.6; margin: 0 0 0.75rem; white-space: pre-wrap; word-break: break-word; } .tc-footer { display: flex; align-items: center; justify-content: space-between; } .tc-actions { display: flex; gap: 1.25rem; } .tc-action { display: flex; align-items: center; gap: 0.3rem; color: var(--rs-text-muted, #64748b); font-size: 0.8rem; cursor: default; } .tc-action svg { width: 16px; height: 16px; } .tc-meta { display: flex; align-items: center; gap: 0.75rem; font-size: 0.75rem; color: var(--rs-text-muted, #64748b); } .tc-chars { font-variant-numeric: tabular-nums; } .tc-chars--over { color: #ef4444; font-weight: 600; } .tc-thread-num { color: #6366f1; font-weight: 600; } .attached-image { position: relative; margin-top: 0.5rem; border-radius: 8px; overflow: hidden; border: 1px solid var(--rs-input-border, #334155); } .attached-image img { display: block; width: 100%; height: auto; } .image-remove { position: absolute; top: 6px; right: 6px; width: 22px; height: 22px; border-radius: 50%; background: rgba(0,0,0,0.7); color: white; border: none; font-size: 0.8rem; cursor: pointer; display: flex; align-items: center; justify-content: center; line-height: 1; transition: background 0.15s; } .image-remove:hover { background: #ef4444; } .export-dropdown { position: relative; } .export-menu { position: absolute; top: calc(100% + 4px); right: 0; z-index: 100; background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-input-border, #334155); border-radius: 8px; min-width: 180px; overflow: hidden; box-shadow: 0 8px 24px var(--rs-shadow-lg, rgba(0,0,0,0.3)); } .export-menu[hidden] { display: none; } .export-menu button { display: block; width: 100%; padding: 0.6rem 0.75rem; border: none; background: transparent; color: var(--rs-text-primary, #f1f5f9); font-size: 0.85rem; text-align: left; cursor: pointer; transition: background 0.1s; } .export-menu button:hover { background: rgba(99,102,241,0.15); } .export-menu button + button { border-top: 1px solid var(--rs-bg-hover, #334155); } .preview { display: flex; flex-direction: column; gap: 0; } .preview-empty { color: var(--rs-text-muted, #64748b); text-align: center; padding: 3rem 1rem; font-size: 0.9rem; } `; } private getEditorStyles(): string { return ` .thread-page { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; max-width: 1100px; margin: 0 auto; padding: 2rem 1rem; min-height: 80vh; } .page-header { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0.5rem; } .page-header h1 { margin: 0; font-size: 1.5rem; color: var(--rs-text-primary, #f1f5f9); background: linear-gradient(135deg, #7dd3fc, #c4b5fd); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } .page-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; } .compose { position: sticky; top: 1rem; align-self: start; display: flex; flex-direction: column; gap: 1rem; } .compose-textarea { width: 100%; min-height: 320px; background: var(--rs-bg-surface, #1e293b); color: var(--rs-text-primary, #f1f5f9); border: 1px solid var(--rs-input-border, #334155); border-radius: 0.75rem; padding: 1rem; font-family: inherit; font-size: 0.9rem; resize: vertical; line-height: 1.6; box-sizing: border-box; } .compose-textarea:focus { outline: none; border-color: #6366f1; } .compose-textarea::placeholder { color: var(--rs-text-muted, #64748b); } .compose-fields { display: flex; gap: 0.75rem; } .compose-input { flex: 1; background: var(--rs-bg-surface, #1e293b); color: var(--rs-text-primary, #f1f5f9); border: 1px solid var(--rs-input-border, #334155); border-radius: 8px; padding: 0.5rem 0.75rem; font-size: 0.85rem; box-sizing: border-box; } .compose-input:focus { outline: none; border-color: #6366f1; } .compose-input::placeholder { color: var(--rs-text-muted, #64748b); } .compose-title { width: 100%; background: var(--rs-bg-surface, #1e293b); color: var(--rs-text-primary, #f1f5f9); border: 1px solid var(--rs-input-border, #334155); border-radius: 8px; padding: 0.5rem 0.75rem; font-size: 0.85rem; box-sizing: border-box; } .compose-title:focus { outline: none; border-color: #6366f1; } .compose-title::placeholder { color: var(--rs-text-muted, #64748b); } .drafts-area { grid-column: 1 / -1; } .drafts-toggle { cursor: pointer; user-select: none; } .drafts-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 0.5rem; margin-top: 0.75rem; } .drafts-list[hidden] { display: none; } .drafts-empty { color: var(--rs-text-muted, #64748b); font-size: 0.8rem; padding: 0.5rem 0; } .draft-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-input-border, #334155); border-radius: 8px; transition: border-color 0.15s; cursor: pointer; } .draft-item:hover { border-color: #6366f1; } .draft-item--active { border-color: #6366f1; background: rgba(99,102,241,0.1); } .draft-item__info { flex: 1; min-width: 0; } .draft-item__info strong { display: block; font-size: 0.8rem; color: var(--rs-text-primary, #f1f5f9); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .draft-item__info span { font-size: 0.7rem; color: var(--rs-text-muted, #64748b); } .draft-item__delete { background: none; border: none; color: var(--rs-text-muted, #64748b); font-size: 1.2rem; cursor: pointer; padding: 0 4px; line-height: 1; flex-shrink: 0; } .draft-item__delete:hover { color: #ef4444; } .image-section { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; } .image-preview { border-radius: 8px; overflow: hidden; border: 1px solid var(--rs-input-border, #334155); } .image-preview[hidden] { display: none; } .image-preview img { display: block; max-width: 200px; height: auto; } #share-link-area { grid-column: 1 / -1; } .share-link { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.75rem; background: rgba(99,102,241,0.1); border: 1px solid #6366f1; border-radius: 8px; font-size: 0.8rem; color: #c4b5fd; } .share-link code { font-size: 0.75rem; color: #7dd3fc; } .share-link button { background: none; border: none; color: var(--rs-text-secondary, #94a3b8); cursor: pointer; font-size: 0.75rem; padding: 2px 6px; } .share-link button:hover { color: var(--rs-text-primary, #f1f5f9); } .photo-btn { position: absolute; top: 8px; right: 8px; z-index: 5; width: 28px; height: 28px; border-radius: 50%; border: 1px solid var(--rs-input-border, #334155); background: var(--rs-bg-surface, #1e293b); color: var(--rs-text-muted, #64748b); cursor: pointer; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.15s, border-color 0.15s, color 0.15s; } .tweet-card:hover .photo-btn { opacity: 1; } .photo-btn:hover { border-color: #6366f1; color: #c4b5fd; } .photo-btn svg { width: 14px; height: 14px; } .photo-plus { position: absolute; bottom: -1px; right: -3px; font-size: 10px; font-weight: 700; color: #6366f1; line-height: 1; } .photo-menu { position: absolute; top: 38px; right: 8px; z-index: 10; background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-input-border, #334155); border-radius: 8px; min-width: 160px; overflow: hidden; box-shadow: 0 8px 24px var(--rs-shadow-lg, rgba(0,0,0,0.3)); } .photo-menu[hidden] { display: none; } .photo-menu button { display: flex; align-items: center; gap: 0.4rem; width: 100%; padding: 0.5rem 0.7rem; border: none; background: transparent; color: var(--rs-text-primary, #f1f5f9); font-size: 0.8rem; cursor: pointer; transition: background 0.1s; } .photo-menu button:hover { background: rgba(99,102,241,0.15); } .photo-menu button + button { border-top: 1px solid var(--rs-bg-hover, #334155); } .photo-menu button svg { width: 14px; height: 14px; } @media (max-width: 700px) { .thread-page { grid-template-columns: 1fr; } .compose { position: static; } } `; } private getReadonlyStyles(): string { return ` .thread-ro { max-width: 640px; margin: 0 auto; padding: 2rem 1rem; } .ro-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem; } .ro-author { display: flex; align-items: center; gap: 0.75rem; } .ro-name { font-weight: 700; color: var(--rs-text-primary, #f1f5f9); font-size: 1.1rem; } .ro-handle { color: var(--rs-text-muted, #64748b); font-size: 0.9rem; } .ro-meta { display: flex; align-items: center; gap: 0.5rem; color: var(--rs-text-muted, #64748b); font-size: 0.85rem; } .ro-title { font-size: 1.4rem; color: var(--rs-text-primary, #f1f5f9); margin: 0 0 1.5rem; line-height: 1.3; } .ro-image { margin-bottom: 1.5rem; border-radius: 12px; overflow: hidden; border: 1px solid var(--rs-input-border, #334155); } .ro-image img { display: block; width: 100%; height: auto; } .ro-cards { margin-bottom: 1.5rem; } .ro-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 2rem; padding-bottom: 2rem; border-bottom: 1px solid var(--rs-input-border, #334155); } .ro-cta { display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap; } .toast { position: fixed; bottom: 1.5rem; left: 50%; transform: translateX(-50%); background: var(--rs-bg-surface, #1e293b); border: 1px solid #6366f1; color: #c4b5fd; padding: 0.6rem 1.25rem; border-radius: 8px; font-size: 0.85rem; box-shadow: 0 4px 16px var(--rs-shadow-lg, rgba(0,0,0,0.3)); z-index: 1000; } .toast[hidden] { display: none; } `; } } customElements.define('folk-thread-builder', FolkThreadBuilder);