From 831c8b1c24268892d241d331bd23dd62780431eb Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 4 Mar 2026 20:42:18 -0800 Subject: [PATCH] feat: refactor rSocials from monolith to full rApp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decompose the 2,116-line mod.ts into a canonical rApp matching the rFlows/rBooks pattern with Automerge sync, web components, and extracted CSS. New files: - schemas.ts: SocialsDoc, ThreadData, Campaign types + Automerge schema - local-first-client.ts: browser-side sync client - lib/types.ts: shared types, DEMO_FEED, PLATFORM_LIMITS - lib/image-gen.ts: server-only fal.ai + file upload helpers - components/folk-thread-builder.ts: compose/preview/readonly component - components/folk-thread-gallery.ts: thread listing grid component - components/folk-campaign-manager.ts: campaign viewer with import modal - components/socials.css: all extracted CSS (~550 lines) mod.ts slimmed to ~616 lines: ensureDoc, image APIs, page routes injecting web components, fileβ†’Automerge migration, seed template, and module export. Thread/campaign CRUD moved from REST to Automerge local-first sync. Co-Authored-By: Claude Opus 4.6 --- .../components/folk-campaign-manager.ts | 314 +++ .../components/folk-thread-builder.ts | 1172 +++++++++ .../components/folk-thread-gallery.ts | 175 ++ modules/rsocials/components/socials.css | 368 +++ modules/rsocials/lib/image-gen.ts | 97 + modules/rsocials/lib/types.ts | 98 + modules/rsocials/local-first-client.ts | 143 ++ modules/rsocials/mod.ts | 2228 +++-------------- modules/rsocials/schemas.ts | 90 + vite.config.ts | 54 +- 10 files changed, 2868 insertions(+), 1871 deletions(-) create mode 100644 modules/rsocials/components/folk-campaign-manager.ts create mode 100644 modules/rsocials/components/folk-thread-builder.ts create mode 100644 modules/rsocials/components/folk-thread-gallery.ts create mode 100644 modules/rsocials/components/socials.css create mode 100644 modules/rsocials/lib/image-gen.ts create mode 100644 modules/rsocials/lib/types.ts create mode 100644 modules/rsocials/local-first-client.ts create mode 100644 modules/rsocials/schemas.ts diff --git a/modules/rsocials/components/folk-campaign-manager.ts b/modules/rsocials/components/folk-campaign-manager.ts new file mode 100644 index 0000000..5ef0806 --- /dev/null +++ b/modules/rsocials/components/folk-campaign-manager.ts @@ -0,0 +1,314 @@ +/** + * β€” Campaign viewer/editor with import modal. + * + * Subscribes to Automerge doc for campaign data. Falls back to MYCOFI_CAMPAIGN + * demo data when no campaigns exist or space=demo. + */ + +import { socialsSchema, socialsDocId } from '../schemas'; +import type { SocialsDoc, Campaign, CampaignPost } from '../schemas'; +import type { DocumentId } from '../../../shared/local-first/document'; +import { MYCOFI_CAMPAIGN, PLATFORM_ICONS, PLATFORM_COLORS } from '../campaign-data'; + +export class FolkCampaignManager extends HTMLElement { + private _space = 'demo'; + private _campaigns: Campaign[] = []; + private _offlineUnsub: (() => void) | null = null; + + static get observedAttributes() { return ['space']; } + + connectedCallback() { + this.attachShadow({ mode: 'open' }); + this._space = this.getAttribute('space') || 'demo'; + // Start with demo campaign + this._campaigns = [{ ...MYCOFI_CAMPAIGN, createdAt: Date.now(), updatedAt: Date.now() }]; + this.render(); + if (this._space !== 'demo') { + this.subscribeOffline(); + } + } + + disconnectedCallback() { + this._offlineUnsub?.(); + this._offlineUnsub = null; + } + + attributeChangedCallback(name: string, _old: string, val: string) { + if (name === 'space') this._space = val; + } + + 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); + this.renderFromDoc(doc); + + this._offlineUnsub = runtime.onChange(docId, (updated: any) => { + this.renderFromDoc(updated); + }); + } catch { + // Runtime unavailable β€” use demo data + } + } + + private renderFromDoc(doc: SocialsDoc) { + if (!doc?.campaigns || Object.keys(doc.campaigns).length === 0) return; + this._campaigns = Object.values(doc.campaigns).sort((a, b) => b.updatedAt - a.updatedAt); + this.render(); + } + + private saveCampaignToDoc(campaign: Campaign) { + const runtime = (window as any).__rspaceOfflineRuntime; + if (!runtime?.isInitialized) return; + + const docId = socialsDocId(this._space) as DocumentId; + runtime.change(docId, `Save campaign ${campaign.title}`, (d: SocialsDoc) => { + if (!d.campaigns) d.campaigns = {} as any; + campaign.updatedAt = Date.now(); + d.campaigns[campaign.id] = campaign; + }); + } + + private esc(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + private renderCampaign(c: Campaign): string { + const phases = [1, 2, 3]; + const phaseIcons = ['πŸ“£', 'πŸš€', 'πŸ“‘']; + + const phaseHTML = phases.map((phaseNum, i) => { + const phasePosts = c.posts.filter(p => p.phase === phaseNum); + if (!phasePosts.length && !c.phases[i]) return ''; + const phaseInfo = c.phases[i] || { label: `Phase ${phaseNum}`, days: '' }; + + const postsHTML = phasePosts.map(post => { + const icon = PLATFORM_ICONS[post.platform] || post.platform; + const color = PLATFORM_COLORS[post.platform] || '#64748b'; + const statusClass = post.status === 'scheduled' ? 'status--scheduled' : 'status--draft'; + const date = new Date(post.scheduledAt); + const dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); + const contentPreview = post.content.length > 180 ? this.esc(post.content.substring(0, 180)) + '...' : this.esc(post.content); + const tags = post.hashtags.map(h => `#${this.esc(h)}`).join(' '); + + return ` +
+
+ ${icon} + + ${this.esc(post.status)} +
+
Step ${post.stepNumber}
+

${contentPreview.replace(/\n/g, '
')}

+ +
`; + }).join(''); + + return ` +
+

${phaseIcons[i] || 'πŸ“‹'} Phase ${phaseNum}: ${this.esc(phaseInfo.label)} ${this.esc(phaseInfo.days)}

+
${postsHTML}
+
`; + }).join(''); + + return ` +
+ πŸ„ +
+

${this.esc(c.title)}

+

${this.esc(c.description)}

+
+ πŸ“… ${this.esc(c.duration)} + πŸ“± ${c.platforms.join(', ')} + πŸ“ ${c.posts.length} posts across ${c.phases.length} phases +
+
+
+
+ Open Thread Builder + +
+ ${phaseHTML} +
`; + } + + private render() { + if (!this.shadowRoot) return; + + const c = this._campaigns[0] || { ...MYCOFI_CAMPAIGN, createdAt: Date.now(), updatedAt: Date.now() }; + const campaignHTML = this.renderCampaign(c); + + this.shadowRoot.innerHTML = ` + +
+ ${campaignHTML} +
+ + `; + + this.bindEvents(); + } + + private bindEvents() { + if (!this.shadowRoot) return; + + const modal = this.shadowRoot.getElementById('import-modal') as HTMLElement; + const openBtn = this.shadowRoot.getElementById('import-md-btn'); + const closeBtn = this.shadowRoot.getElementById('import-modal-close'); + const parseBtn = this.shadowRoot.getElementById('import-parse-btn'); + const mdInput = this.shadowRoot.getElementById('import-md-textarea') as HTMLTextAreaElement; + const platformSel = this.shadowRoot.getElementById('import-platform') as HTMLSelectElement; + const importedEl = this.shadowRoot.getElementById('imported-posts'); + + openBtn?.addEventListener('click', () => { modal.hidden = false; }); + closeBtn?.addEventListener('click', () => { modal.hidden = true; }); + modal?.addEventListener('click', (e) => { if (e.target === modal) modal.hidden = true; }); + + parseBtn?.addEventListener('click', () => { + const raw = mdInput.value; + const tweets = raw.split(/\n---\n/).map(t => t.trim()).filter(Boolean); + if (!tweets.length || !importedEl) return; + const platform = platformSel.value; + const total = tweets.length; + + // Build imported posts as campaign posts and save to Automerge + const posts: CampaignPost[] = tweets.map((text, i) => ({ + id: `imported-${Date.now()}-${i}`, + platform, + postType: 'text', + stepNumber: i + 1, + content: text, + scheduledAt: new Date().toISOString(), + status: 'imported', + hashtags: [], + phase: 1, + phaseLabel: 'Imported', + })); + + // Render imported posts inline + let html = `

πŸ“₯ Imported Posts (${total})

`; + html += '
'; + tweets.forEach((text, i) => { + const preview = text.length > 180 ? this.esc(text.substring(0, 180)) + '...' : this.esc(text); + html += `
+
+ ${this.esc(platform.charAt(0).toUpperCase())} + + imported +
+
Tweet ${i + 1}/${total}
+

${preview.replace(/\n/g, '
')}

+
`; + }); + html += '
'; + importedEl.innerHTML = html; + modal.hidden = true; + + // Save to Automerge if runtime available + if (this._space !== 'demo') { + const c = this._campaigns[0]; + if (c) { + c.posts = [...c.posts, ...posts]; + this.saveCampaignToDoc(c); + } + } + }); + } +} + +customElements.define('folk-campaign-manager', FolkCampaignManager); diff --git a/modules/rsocials/components/folk-thread-builder.ts b/modules/rsocials/components/folk-thread-builder.ts new file mode 100644 index 0000000..65a0a55 --- /dev/null +++ b/modules/rsocials/components/folk-thread-builder.ts @@ -0,0 +1,1172 @@ +/** + * β€” 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); diff --git a/modules/rsocials/components/folk-thread-gallery.ts b/modules/rsocials/components/folk-thread-gallery.ts new file mode 100644 index 0000000..38c9789 --- /dev/null +++ b/modules/rsocials/components/folk-thread-gallery.ts @@ -0,0 +1,175 @@ +/** + * β€” Thread listing grid with cards. + * + * Subscribes to Automerge doc and renders all threads sorted by updatedAt. + * Falls back to demo data when space=demo. + */ + +import { socialsSchema, socialsDocId } from '../schemas'; +import type { SocialsDoc, ThreadData } from '../schemas'; +import type { DocumentId } from '../../../shared/local-first/document'; +import { DEMO_FEED } from '../lib/types'; + +export class FolkThreadGallery extends HTMLElement { + private _space = 'demo'; + private _threads: ThreadData[] = []; + private _offlineUnsub: (() => void) | null = null; + + static get observedAttributes() { return ['space']; } + + connectedCallback() { + this.attachShadow({ mode: 'open' }); + this._space = this.getAttribute('space') || 'demo'; + this.render(); + if (this._space === 'demo') { + this.loadDemoData(); + } else { + this.subscribeOffline(); + } + } + + disconnectedCallback() { + this._offlineUnsub?.(); + this._offlineUnsub = null; + } + + attributeChangedCallback(name: string, _old: string, val: string) { + if (name === 'space') this._space = val; + } + + 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); + this.renderFromDoc(doc); + + this._offlineUnsub = runtime.onChange(docId, (updated: any) => { + this.renderFromDoc(updated); + }); + } catch { + // Runtime unavailable + } + } + + private renderFromDoc(doc: SocialsDoc) { + if (!doc?.threads) return; + this._threads = Object.values(doc.threads).sort((a, b) => b.updatedAt - a.updatedAt); + this.render(); + } + + private loadDemoData() { + this._threads = [ + { + id: 'demo-1', name: 'Alice', handle: '@alice', + title: 'Building local-first apps with rSpace', + tweets: ['Just deployed the new rFlows river view! The enoughness score is such a powerful concept.', 'The key insight: local-first means your data is always available, even offline.', 'And with Automerge, real-time sync just works. No conflict resolution needed.'], + createdAt: Date.now() - 86400000, updatedAt: Date.now() - 3600000, + }, + { + id: 'demo-2', name: 'Bob', handle: '@bob', + title: 'Why cosmolocal production matters', + tweets: ['The cosmolocal print network now has 6 providers across 4 countries.', 'Design global, manufacture local β€” this is the future of sustainable production.'], + createdAt: Date.now() - 172800000, updatedAt: Date.now() - 86400000, + }, + { + id: 'demo-3', name: 'Carol', handle: '@carol', + title: 'Governance lessons from Elinor Ostrom', + tweets: ['Reading "Governing the Commons" β€” so many parallels to what we\'re building.', 'Ostrom\'s 8 principles for managing commons map perfectly to DAO governance.', 'The key: graduated sanctions and local monitoring. Not one-size-fits-all.'], + createdAt: Date.now() - 259200000, updatedAt: Date.now() - 172800000, + }, + ]; + this.render(); + } + + private esc(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + private render() { + if (!this.shadowRoot) return; + const space = this._space; + const threads = this._threads; + + const cardsHTML = threads.length === 0 + ? `
+

No threads yet. Create your first thread!

+ Create Thread +
` + : `
+ ${threads.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 + ? `
` + : ''; + 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 = ` + + + `; + } +} + +customElements.define('folk-thread-gallery', FolkThreadGallery); diff --git a/modules/rsocials/components/socials.css b/modules/rsocials/components/socials.css new file mode 100644 index 0000000..ede225d --- /dev/null +++ b/modules/rsocials/components/socials.css @@ -0,0 +1,368 @@ +/** + * rSocials β€” all extracted CSS. + * + * Combined from inline CSS blocks previously in mod.ts: + * - Campaign page styles + * - Thread builder styles + * - Thread read-only styles + * - Thread gallery styles + * - Demo feed styles + */ + +/* ── Campaign page ── */ + +.campaign-page { max-width: 900px; margin: 0 auto; padding: 2rem 1rem; } +.campaign-page__header { display: flex; align-items: flex-start; gap: 1rem; margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid var(--rs-input-border); } +.campaign-page__icon { font-size: 3rem; } +.campaign-page__title { margin: 0; font-size: 1.5rem; color: var(--rs-text-primary); } +.campaign-page__desc { margin: 0.25rem 0 0.5rem; color: var(--rs-text-secondary); font-size: 0.9rem; line-height: 1.5; } +.campaign-page__stats { display: flex; flex-wrap: wrap; gap: 1rem; font-size: 0.8rem; color: var(--rs-text-muted); } +.campaign-phase { margin-bottom: 2rem; } +.campaign-phase__title { font-size: 1.15rem; color: var(--rs-text-primary); margin: 0 0 1rem; display: flex; align-items: center; gap: 0.5rem; } +.campaign-phase__days { font-size: 0.8rem; color: var(--rs-text-muted); font-weight: 400; } +.campaign-phase__posts { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.75rem; } +.campaign-post { + background: var(--rs-bg-surface); border: 1px solid var(--rs-input-border); border-radius: 0.75rem; padding: 1rem; + transition: border-color 0.15s; +} +.campaign-post:hover { border-color: #6366f1; } +.campaign-post__header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; } +.campaign-post__platform { + width: 28px; height: 28px; border-radius: 6px; display: flex; align-items: center; justify-content: center; + color: white; font-size: 0.75rem; font-weight: 700; flex-shrink: 0; +} +.campaign-post__meta { flex: 1; min-width: 0; } +.campaign-post__meta strong { display: block; font-size: 0.8rem; color: var(--rs-text-primary); text-transform: capitalize; } +.campaign-post__date { font-size: 0.7rem; color: var(--rs-text-muted); } +.campaign-post__step { font-size: 0.65rem; color: #6366f1; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; } +.campaign-status { font-size: 0.6rem; padding: 2px 6px; border-radius: 4px; text-transform: uppercase; font-weight: 600; letter-spacing: 0.05em; white-space: nowrap; } +.campaign-status--scheduled { background: rgba(16,185,129,0.15); color: #34d399; } +.campaign-status--draft { background: rgba(251,191,36,0.15); color: #fbbf24; } +.campaign-post__content { font-size: 0.8rem; color: var(--rs-text-secondary); line-height: 1.5; margin: 0 0 0.5rem; } +.campaign-post__tags { display: flex; flex-wrap: wrap; gap: 0.25rem; } +.campaign-tag { font-size: 0.65rem; color: #7dd3fc; } +.campaign-page__actions { display: flex; gap: 0.75rem; margin-bottom: 1.5rem; } +.campaign-action-btn { + padding: 0.5rem 1rem; border-radius: 8px; font-size: 0.85rem; font-weight: 600; + cursor: pointer; transition: all 0.15s; text-decoration: none; display: inline-flex; align-items: center; +} +.campaign-action-btn--primary { background: #6366f1; color: white; border: none; } +.campaign-action-btn--primary:hover { background: #818cf8; } +.campaign-action-btn--outline { background: transparent; color: var(--rs-text-secondary); border: 1px solid var(--rs-input-border); } +.campaign-action-btn--outline:hover { border-color: #6366f1; color: #c4b5fd; } +.campaign-modal-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; + align-items: center; justify-content: center; z-index: 1000; +} +.campaign-modal-overlay[hidden] { display: none; } +.campaign-modal { + background: var(--rs-bg-surface); border: 1px solid var(--rs-input-border); border-radius: 0.75rem; + padding: 1.5rem; width: 90%; max-width: 540px; display: flex; flex-direction: column; gap: 1rem; +} +.campaign-modal__header { display: flex; align-items: center; justify-content: space-between; } +.campaign-modal__header h3 { margin: 0; font-size: 1.1rem; color: var(--rs-text-primary); } +.campaign-modal__close { + background: none; border: none; color: var(--rs-text-muted); font-size: 1.5rem; cursor: pointer; + line-height: 1; padding: 0; +} +.campaign-modal__close:hover { color: var(--rs-text-primary); } +.campaign-modal__textarea { + width: 100%; min-height: 200px; background: var(--rs-input-bg); color: var(--rs-input-text); border: 1px solid var(--rs-input-border); + border-radius: 8px; padding: 0.75rem; font-family: inherit; font-size: 0.85rem; resize: vertical; + line-height: 1.5; box-sizing: border-box; +} +.campaign-modal__textarea:focus { outline: none; border-color: #6366f1; } +.campaign-modal__textarea::placeholder { color: var(--rs-text-muted); } +.campaign-modal__row { display: flex; gap: 0.75rem; align-items: center; } +.campaign-modal__select { + flex: 1; background: var(--rs-input-bg); color: var(--rs-input-text); border: 1px solid var(--rs-input-border); + border-radius: 8px; padding: 0.5rem 0.75rem; font-size: 0.85rem; +} +.campaign-modal__select:focus { outline: none; border-color: #6366f1; } + +/* ── Thread builder ── */ + +.thread-page { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; max-width: 1100px; margin: 0 auto; padding: 2rem 1rem; min-height: 80vh; } +.thread-page__header { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0.5rem; } +.thread-page__header h1 { margin: 0; font-size: 1.5rem; color: var(--rs-text-primary); background: linear-gradient(135deg, #7dd3fc, #c4b5fd); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } +.thread-page__actions { display: flex; gap: 0.5rem; flex-wrap: wrap; } +.thread-btn { padding: 0.5rem 1rem; border-radius: 8px; border: none; font-size: 0.85rem; font-weight: 600; cursor: pointer; transition: all 0.15s; } +.thread-btn--primary { background: #6366f1; color: white; } +.thread-btn--primary:hover { background: #818cf8; } +.thread-btn--outline { background: transparent; color: var(--rs-text-secondary); border: 1px solid var(--rs-input-border); } +.thread-btn--outline:hover { border-color: #6366f1; color: #c4b5fd; } +.thread-btn--success { background: #10b981; color: white; } +.thread-btn--success:hover { background: #34d399; } +.thread-btn:disabled { opacity: 0.5; cursor: not-allowed; } +.thread-compose { position: sticky; top: 1rem; align-self: start; display: flex; flex-direction: column; gap: 1rem; } +.thread-compose__textarea { + width: 100%; min-height: 320px; background: var(--rs-bg-surface); color: var(--rs-text-primary); border: 1px solid var(--rs-input-border); + border-radius: 0.75rem; padding: 1rem; font-family: inherit; font-size: 0.9rem; resize: vertical; + line-height: 1.6; box-sizing: border-box; +} +.thread-compose__textarea:focus { outline: none; border-color: #6366f1; } +.thread-compose__textarea::placeholder { color: var(--rs-text-muted); } +.thread-compose__fields { display: flex; gap: 0.75rem; } +.thread-compose__input { + flex: 1; background: var(--rs-bg-surface); color: var(--rs-text-primary); border: 1px solid var(--rs-input-border); + border-radius: 8px; padding: 0.5rem 0.75rem; font-size: 0.85rem; box-sizing: border-box; +} +.thread-compose__input:focus { outline: none; border-color: #6366f1; } +.thread-compose__input::placeholder { color: var(--rs-text-muted); } +.thread-compose__title { + width: 100%; background: var(--rs-bg-surface); color: var(--rs-text-primary); border: 1px solid var(--rs-input-border); + border-radius: 8px; padding: 0.5rem 0.75rem; font-size: 0.85rem; box-sizing: border-box; +} +.thread-compose__title:focus { outline: none; border-color: #6366f1; } +.thread-compose__title::placeholder { color: var(--rs-text-muted); } +.thread-drafts { grid-column: 1 / -1; } +.thread-drafts__toggle { cursor: pointer; user-select: none; } +.thread-drafts__list { + display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 0.5rem; + margin-top: 0.75rem; +} +.thread-drafts__list[hidden] { display: none; } +.thread-drafts__empty { color: var(--rs-text-muted); font-size: 0.8rem; padding: 0.5rem 0; } +.thread-draft-item { + display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; + background: var(--rs-bg-surface); border: 1px solid var(--rs-input-border); border-radius: 8px; + transition: border-color 0.15s; cursor: pointer; +} +.thread-draft-item:hover { border-color: #6366f1; } +.thread-draft-item--active { border-color: #6366f1; background: rgba(99,102,241,0.1); } +.thread-draft-item__info { flex: 1; min-width: 0; } +.thread-draft-item__info strong { display: block; font-size: 0.8rem; color: var(--rs-text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.thread-draft-item__info span { font-size: 0.7rem; color: var(--rs-text-muted); } +.thread-draft-item__delete { + background: none; border: none; color: var(--rs-text-muted); font-size: 1.2rem; cursor: pointer; + padding: 0 4px; line-height: 1; flex-shrink: 0; +} +.thread-draft-item__delete:hover { color: #ef4444; } +.thread-image-section { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; } +.thread-image-preview { border-radius: 8px; overflow: hidden; border: 1px solid var(--rs-input-border); } +.thread-image-preview[hidden] { display: none; } +.thread-image-preview img { display: block; max-width: 200px; height: auto; } +#share-link-area { grid-column: 1 / -1; } +.thread-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; +} +.thread-share-link code { font-size: 0.75rem; color: #7dd3fc; } +.thread-share-link button { + background: none; border: none; color: var(--rs-text-secondary); cursor: pointer; font-size: 0.75rem; padding: 2px 6px; +} +.thread-share-link button:hover { color: var(--rs-text-primary); } +.thread-preview { display: flex; flex-direction: column; gap: 0; } +.thread-preview__empty { color: var(--rs-text-muted); text-align: center; padding: 3rem 1rem; font-size: 0.9rem; } +.tweet-card { + position: relative; background: var(--rs-bg-surface); border: 1px solid var(--rs-input-border); 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; } +.tweet-card__connector { + position: absolute; left: 29px; top: -1px; width: 2px; height: 1rem; + background: var(--rs-input-border); z-index: 1; +} +.tweet-card__header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; } +.tweet-card__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; +} +.tweet-card__name { font-weight: 700; color: var(--rs-text-primary); font-size: 0.9rem; } +.tweet-card__handle { color: var(--rs-text-muted); font-size: 0.85rem; } +.tweet-card__dot { color: var(--rs-text-muted); font-size: 0.85rem; } +.tweet-card__time { color: var(--rs-text-muted); font-size: 0.85rem; } +.tweet-card__content { color: var(--rs-text-primary); font-size: 0.95rem; line-height: 1.6; margin: 0 0 0.75rem; white-space: pre-wrap; word-break: break-word; } +.tweet-card__footer { display: flex; align-items: center; justify-content: space-between; } +.tweet-card__actions { display: flex; gap: 1.25rem; } +.tweet-card__action { display: flex; align-items: center; gap: 0.3rem; color: var(--rs-text-muted); font-size: 0.8rem; cursor: default; } +.tweet-card__action svg { width: 16px; height: 16px; } +.tweet-card__meta { display: flex; align-items: center; gap: 0.75rem; font-size: 0.75rem; color: var(--rs-text-muted); } +.tweet-card__chars { font-variant-numeric: tabular-nums; } +.tweet-card__chars--over { color: #ef4444; font-weight: 600; } +.tweet-card__thread-num { color: #6366f1; font-weight: 600; } +@media (max-width: 700px) { + .thread-page { grid-template-columns: 1fr; } + .thread-compose { position: static; } +} +.thread-export-dropdown { position: relative; } +.thread-export-menu { + position: absolute; top: calc(100% + 4px); right: 0; z-index: 100; + background: var(--rs-bg-surface); border: 1px solid var(--rs-input-border); border-radius: 8px; + min-width: 180px; overflow: hidden; + box-shadow: 0 8px 24px var(--rs-shadow-lg); +} +.thread-export-menu[hidden] { display: none; } +.thread-export-menu button { + display: block; width: 100%; padding: 0.6rem 0.75rem; border: none; + background: transparent; color: var(--rs-text-primary); font-size: 0.85rem; + text-align: left; cursor: pointer; transition: background 0.1s; +} +.thread-export-menu button:hover { background: rgba(99,102,241,0.15); } +.thread-export-menu button + button { border-top: 1px solid var(--rs-bg-hover); } +.tweet-card__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); + background: var(--rs-bg-surface); color: var(--rs-text-muted); 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 .tweet-card__photo-btn { opacity: 1; } +.tweet-card__photo-btn:hover { border-color: #6366f1; color: #c4b5fd; } +.tweet-card__photo-btn svg { width: 14px; height: 14px; } +.tweet-card__photo-btn .photo-btn-plus { + position: absolute; bottom: -1px; right: -3px; font-size: 10px; font-weight: 700; + color: #6366f1; line-height: 1; +} +.tweet-card__photo-menu { + position: absolute; top: 38px; right: 8px; z-index: 10; + background: var(--rs-bg-surface); border: 1px solid var(--rs-input-border); border-radius: 8px; + min-width: 160px; overflow: hidden; box-shadow: 0 8px 24px var(--rs-shadow-lg); +} +.tweet-card__photo-menu[hidden] { display: none; } +.tweet-card__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); font-size: 0.8rem; cursor: pointer; transition: background 0.1s; +} +.tweet-card__photo-menu button:hover { background: rgba(99,102,241,0.15); } +.tweet-card__photo-menu button + button { border-top: 1px solid var(--rs-bg-hover); } +.tweet-card__photo-menu button svg { width: 14px; height: 14px; } +.tweet-card__attached-image { position: relative; margin-top: 0.5rem; border-radius: 8px; overflow: hidden; border: 1px solid var(--rs-input-border); } +.tweet-card__attached-image img { display: block; width: 100%; height: auto; } +.tweet-card__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; +} +.tweet-card__image-remove:hover { background: #ef4444; } + +/* ── Thread read-only ── */ + +.thread-ro { max-width: 640px; margin: 0 auto; padding: 2rem 1rem; } +.thread-ro__header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem; } +.thread-ro__author { display: flex; align-items: center; gap: 0.75rem; } +.thread-ro__name { font-weight: 700; color: var(--rs-text-primary); font-size: 1.1rem; } +.thread-ro__handle { color: var(--rs-text-muted); font-size: 0.9rem; } +.thread-ro__meta { display: flex; align-items: center; gap: 0.5rem; color: var(--rs-text-muted); font-size: 0.85rem; } +.thread-ro__title { font-size: 1.4rem; color: var(--rs-text-primary); margin: 0 0 1.5rem; line-height: 1.3; } +.thread-ro__image { margin-bottom: 1.5rem; border-radius: 12px; overflow: hidden; border: 1px solid var(--rs-input-border); } +.thread-ro__image img { display: block; width: 100%; height: auto; } +.thread-ro__cards { margin-bottom: 1.5rem; } +.thread-ro__actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 2rem; padding-bottom: 2rem; border-bottom: 1px solid var(--rs-input-border); } +.thread-ro__cta { display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap; } +.thread-export-toast { + position: fixed; bottom: 1.5rem; left: 50%; transform: translateX(-50%); + background: var(--rs-bg-surface); 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); z-index: 1000; + transition: opacity 0.2s; +} +.thread-export-toast[hidden] { display: none; } + +/* ── Thread gallery ── */ + +.threads-gallery { max-width: 900px; margin: 0 auto; padding: 2rem 1rem; } +.threads-gallery__header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 0.75rem; } +.threads-gallery__header h1 { + margin: 0; font-size: 1.5rem; + background: linear-gradient(135deg, #7dd3fc, #c4b5fd); + -webkit-background-clip: text; -webkit-text-fill-color: transparent; +} +.threads-gallery__grid { + display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; +} +.threads-gallery__empty { color: var(--rs-text-muted); text-align: center; padding: 3rem 1rem; font-size: 0.9rem; } +.thread-card { + background: var(--rs-bg-surface); border: 1px solid var(--rs-input-border); border-radius: 0.75rem; + padding: 1.25rem; transition: border-color 0.15s, transform 0.15s; + display: flex; flex-direction: column; gap: 0.75rem; + text-decoration: none; color: inherit; +} +.thread-card:hover { border-color: #6366f1; transform: translateY(-2px); } +.thread-card__title { font-size: 1rem; font-weight: 700; color: var(--rs-text-primary); margin: 0; line-height: 1.3; } +.thread-card__preview { + font-size: 0.85rem; color: var(--rs-text-secondary); line-height: 1.5; + display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; +} +.thread-card__meta { display: flex; align-items: center; gap: 0.75rem; font-size: 0.75rem; color: var(--rs-text-muted); margin-top: auto; } +.thread-card__author { display: flex; align-items: center; gap: 0.4rem; } +.thread-card__avatar-sm { + width: 20px; height: 20px; border-radius: 50%; background: #6366f1; + display: flex; align-items: center; justify-content: center; + color: white; font-weight: 700; font-size: 0.55rem; flex-shrink: 0; +} +.thread-card__image { border-radius: 8px; overflow: hidden; border: 1px solid var(--rs-input-border); margin-bottom: 0.25rem; } +.thread-card__image img { display: block; width: 100%; height: 120px; object-fit: cover; } + +/* ── Demo feed ── */ + +.rsocials-app { max-width: 640px; margin: 0 auto; padding: 2rem 1rem; } +.rsocials-header { margin-bottom: 1.5rem; } +.rsocials-header h2 { + font-size: 1.5rem; margin: 0 0 0.25rem; display: flex; align-items: center; gap: 0.75rem; + background: linear-gradient(135deg, #7dd3fc, #c4b5fd); + -webkit-background-clip: text; -webkit-text-fill-color: transparent; +} +.rsocials-demo-badge { + font-size: 0.6rem; font-weight: 700; letter-spacing: 0.08em; + background: #6366f1; color: white; + -webkit-text-fill-color: white; + padding: 2px 8px; border-radius: 4px; + text-transform: uppercase; line-height: 1.6; +} +.rsocials-subtitle { color: var(--rs-text-muted); font-size: 0.85rem; margin: 0; } +.rsocials-feed { display: flex; flex-direction: column; gap: 1px; } +.rsocials-loading { color: var(--rs-text-muted); padding: 2rem 0; text-align: center; } +.rsocials-empty { color: var(--rs-text-muted); padding: 2rem 0; text-align: center; } +.rsocials-item { + padding: 1rem; border-radius: 8px; + background: var(--rs-bg-surface); border: 1px solid var(--rs-border-subtle); + margin-bottom: 0.5rem; + transition: border-color 0.2s; +} +.rsocials-item:hover { border-color: rgba(99,102,241,0.3); } +.rsocials-item-header { + display: flex; align-items: center; gap: 0.75rem; + font-size: 0.85rem; color: var(--rs-text-secondary); margin-bottom: 0.5rem; +} +.rsocials-avatar { + width: 36px; height: 36px; border-radius: 50%; + display: flex; align-items: center; justify-content: center; + color: white; font-weight: 700; font-size: 0.85rem; + flex-shrink: 0; +} +.rsocials-meta { + display: flex; flex-direction: column; gap: 1px; +} +.rsocials-meta strong { color: var(--rs-text-primary); font-size: 0.9rem; } +.rsocials-meta time { font-size: 0.75rem; color: var(--rs-text-muted); } +.rsocials-item-header strong { color: var(--rs-text-primary); } +.rsocials-item-header time { margin-left: auto; font-size: 0.75rem; } +.rsocials-source { + font-size: 0.65rem; padding: 1px 6px; border-radius: 4px; + background: rgba(124,58,237,0.15); color: #c4b5fd; + text-transform: uppercase; letter-spacing: 0.05em; +} +.rsocials-item-content { margin: 0 0 0.75rem; color: var(--rs-text-primary); line-height: 1.6; font-size: 0.9rem; } +.rsocials-item-link { + display: block; font-size: 0.8rem; color: #7dd3fc; + text-decoration: none; margin-bottom: 0.5rem; word-break: break-all; +} +.rsocials-item-link:hover { text-decoration: underline; } +.rsocials-item-actions { + display: flex; gap: 1rem; font-size: 0.8rem; color: var(--rs-text-muted); +} +.rsocials-action { + display: flex; align-items: center; gap: 0.35rem; + cursor: default; +} +.rsocials-action svg { opacity: 0.7; } +.rsocials-demo-notice { + text-align: center; font-size: 0.75rem; color: var(--rs-text-muted); + padding: 1rem 0; border-top: 1px solid var(--rs-border-subtle); margin-top: 0.5rem; +} diff --git a/modules/rsocials/lib/image-gen.ts b/modules/rsocials/lib/image-gen.ts new file mode 100644 index 0000000..7a2ae82 --- /dev/null +++ b/modules/rsocials/lib/image-gen.ts @@ -0,0 +1,97 @@ +/** + * Server-side image generation and file upload helpers. + * + * These functions require filesystem access and FAL_KEY, + * so they stay server-only (not bundled into web components). + */ + +import { resolve } from "node:path"; +import { mkdir, writeFile, unlink } from "node:fs/promises"; + +const GEN_DIR = resolve(process.env.FILES_DIR || "./data/files", "generated"); + +async function ensureGenDir(): Promise { + await mkdir(GEN_DIR, { recursive: true }); + return GEN_DIR; +} + +// ── fal.ai image generation ── + +export async function generateImageFromPrompt(prompt: string): Promise { + const FAL_KEY = process.env.FAL_KEY || ""; + if (!FAL_KEY) return null; + + const falRes = await fetch("https://fal.run/fal-ai/flux-pro/v1.1", { + method: "POST", + headers: { + Authorization: `Key ${FAL_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + prompt, + image_size: "landscape_4_3", + num_images: 1, + safety_tolerance: "2", + }), + }); + + if (!falRes.ok) { + console.error("[image-gen] fal.ai error:", await falRes.text()); + return null; + } + + const falData = await falRes.json() as { images?: { url: string }[]; output?: { url: string } }; + return falData.images?.[0]?.url || falData.output?.url || null; +} + +export async function downloadAndSaveImage(cdnUrl: string, filename: string): Promise { + const imgRes = await fetch(cdnUrl); + if (!imgRes.ok) return null; + + const imgBuffer = await imgRes.arrayBuffer(); + const dir = await ensureGenDir(); + await writeFile(resolve(dir, filename), Buffer.from(imgBuffer)); + return `/data/files/generated/${filename}`; +} + +// ── File upload handling ── + +const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"]; +const MAX_SIZE = 5 * 1024 * 1024; // 5MB + +export function validateImageFile(file: File): string | null { + if (!ALLOWED_TYPES.includes(file.type)) { + return "Invalid file type. Allowed: png, jpg, webp, gif"; + } + if (file.size > MAX_SIZE) { + return "File too large. Maximum 5MB"; + } + return null; +} + +export function safeExtension(filename: string): string { + const ext = filename.split(".").pop()?.toLowerCase() || "png"; + return ["png", "jpg", "jpeg", "webp", "gif"].includes(ext) ? ext : "png"; +} + +export async function saveUploadedFile(buffer: Buffer, filename: string): Promise { + const dir = await ensureGenDir(); + await writeFile(resolve(dir, filename), buffer); + return `/data/files/generated/${filename}`; +} + +// ── Cleanup helpers ── + +export async function deleteImageFile(imageUrl: string): Promise { + const fname = imageUrl.split("/").pop(); + if (!fname) return; + try { await unlink(resolve(GEN_DIR, fname)); } catch { /* ignore */ } +} + +export async function deleteOldImage(oldUrl: string | undefined, newFilename: string): Promise { + if (!oldUrl) return; + const oldFilename = oldUrl.split("/").pop(); + if (oldFilename && oldFilename !== newFilename) { + try { await unlink(resolve(GEN_DIR, oldFilename)); } catch { /* ignore */ } + } +} diff --git a/modules/rsocials/lib/types.ts b/modules/rsocials/lib/types.ts new file mode 100644 index 0000000..5d6cd86 --- /dev/null +++ b/modules/rsocials/lib/types.ts @@ -0,0 +1,98 @@ +/** + * rSocials shared types and constants. + * + * Used by both server (mod.ts) and client (web components). + */ + +// ── Feed types ── + +export interface FeedItem { + id: string; + type: "post" | "link"; + author: string; + content: string; + url?: string; + source: string; + timestamp: string; + likes: number; + replies: number; +} + +// ── Demo feed data ── + +export interface DemoFeedPost { + username: string; + initial: string; + color: string; + content: string; + timeAgo: string; + likes: number; + replies: number; +} + +export const DEMO_FEED: DemoFeedPost[] = [ + { + username: "@alice", + initial: "A", + color: "#6366f1", + content: "Just deployed the new rFlows river view! The enoughness score is such a powerful concept. \u{1F30A}", + timeAgo: "2 hours ago", + likes: 5, + replies: 2, + }, + { + username: "@bob", + initial: "B", + color: "#f59e0b", + content: "Workshop recording is up on rTube: 'Introduction to Local-First Data'. Check it out!", + timeAgo: "5 hours ago", + likes: 8, + replies: 4, + }, + { + username: "@carol", + initial: "C", + color: "#10b981", + content: "The cosmolocal print network now has 6 providers across 4 countries. Design global, manufacture local! \u{1F30D}", + timeAgo: "1 day ago", + likes: 12, + replies: 3, + }, + { + username: "@diana", + initial: "D", + color: "#ec4899", + content: "Reading Elinor Ostrom's 'Governing the Commons' \u2014 so many parallels to what we're building with rSpace governance.", + timeAgo: "1 day ago", + likes: 7, + replies: 5, + }, + { + username: "@eve", + initial: "E", + color: "#14b8a6", + content: "New community garden plot assignments are up on rChoices. Vote for your preferred plot by Friday!", + timeAgo: "2 days ago", + likes: 3, + replies: 1, + }, + { + username: "@frank", + initial: "F", + color: "#8b5cf6", + content: "Mesh network node #42 is online! Coverage now extends to the community center. \u{1F4E1}", + timeAgo: "3 days ago", + likes: 15, + replies: 6, + }, +]; + +// ── Platform character limits ── + +export const PLATFORM_LIMITS: Record = { + twitter: 280, + bluesky: 300, + mastodon: 500, + linkedin: 3000, + plain: Infinity, +}; diff --git a/modules/rsocials/local-first-client.ts b/modules/rsocials/local-first-client.ts new file mode 100644 index 0000000..807aa44 --- /dev/null +++ b/modules/rsocials/local-first-client.ts @@ -0,0 +1,143 @@ +/** + * rSocials Local-First Client + * + * Wraps the shared local-first stack for thread and campaign data. + * One Automerge doc per space stores all threads and campaigns. + */ + +import { DocumentManager } from '../../shared/local-first/document'; +import type { DocumentId } from '../../shared/local-first/document'; +import { EncryptedDocStore } from '../../shared/local-first/storage'; +import { DocSyncManager } from '../../shared/local-first/sync'; +import { DocCrypto } from '../../shared/local-first/crypto'; +import { socialsSchema, socialsDocId } from './schemas'; +import type { SocialsDoc, ThreadData, Campaign } from './schemas'; + +export class SocialsLocalFirstClient { + #space: string; + #documents: DocumentManager; + #store: EncryptedDocStore; + #sync: DocSyncManager; + #initialized = false; + + constructor(space: string, docCrypto?: DocCrypto) { + this.#space = space; + this.#documents = new DocumentManager(); + this.#store = new EncryptedDocStore(space, docCrypto); + this.#sync = new DocSyncManager({ + documents: this.#documents, + store: this.#store, + }); + this.#documents.registerSchema(socialsSchema); + } + + get isConnected(): boolean { return this.#sync.isConnected; } + + async init(): Promise { + if (this.#initialized) return; + await this.#store.open(); + const cachedIds = await this.#store.listByModule('socials', 'data'); + const cached = await this.#store.loadMany(cachedIds); + for (const [docId, binary] of cached) { + this.#documents.open(docId, socialsSchema, binary); + } + await this.#sync.preloadSyncStates(cachedIds); + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; + try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[SocialsClient] Working offline'); } + this.#initialized = true; + } + + async subscribe(): Promise { + const docId = socialsDocId(this.#space) as DocumentId; + let doc = this.#documents.get(docId); + if (!doc) { + const binary = await this.#store.load(docId); + doc = binary + ? this.#documents.open(docId, socialsSchema, binary) + : this.#documents.open(docId, socialsSchema); + } + await this.#sync.subscribe([docId]); + return doc ?? null; + } + + // ── Reads ── + + getDoc(): SocialsDoc | undefined { + return this.#documents.get(socialsDocId(this.#space) as DocumentId); + } + + listThreads(): ThreadData[] { + const doc = this.getDoc(); + if (!doc?.threads) return []; + return Object.values(doc.threads).sort((a, b) => b.updatedAt - a.updatedAt); + } + + getThread(id: string): ThreadData | undefined { + const doc = this.getDoc(); + return doc?.threads?.[id]; + } + + listCampaigns(): Campaign[] { + const doc = this.getDoc(); + if (!doc?.campaigns) return []; + return Object.values(doc.campaigns).sort((a, b) => b.updatedAt - a.updatedAt); + } + + getCampaign(id: string): Campaign | undefined { + const doc = this.getDoc(); + return doc?.campaigns?.[id]; + } + + // ── Thread writes ── + + saveThread(thread: ThreadData): void { + const docId = socialsDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Save thread ${thread.title || thread.id}`, (d) => { + if (!d.threads) d.threads = {} as any; + thread.updatedAt = Date.now(); + if (!thread.createdAt) thread.createdAt = Date.now(); + d.threads[thread.id] = thread; + }); + } + + deleteThread(id: string): void { + const docId = socialsDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Delete thread ${id}`, (d) => { + if (d.threads?.[id]) delete d.threads[id]; + }); + } + + // ── Campaign writes ── + + saveCampaign(campaign: Campaign): void { + const docId = socialsDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Save campaign ${campaign.title || campaign.id}`, (d) => { + if (!d.campaigns) d.campaigns = {} as any; + campaign.updatedAt = Date.now(); + if (!campaign.createdAt) campaign.createdAt = Date.now(); + d.campaigns[campaign.id] = campaign; + }); + } + + deleteCampaign(id: string): void { + const docId = socialsDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Delete campaign ${id}`, (d) => { + if (d.campaigns?.[id]) delete d.campaigns[id]; + }); + } + + // ── Events ── + + onChange(cb: (doc: SocialsDoc) => void): () => void { + return this.#sync.onChange(socialsDocId(this.#space) as DocumentId, cb as (doc: any) => void); + } + + onConnect(cb: () => void): () => void { return this.#sync.onConnect(cb); } + onDisconnect(cb: () => void): () => void { return this.#sync.onDisconnect(cb); } + + async disconnect(): Promise { + await this.#sync.flush(); + this.#sync.disconnect(); + } +} diff --git a/modules/rsocials/mod.ts b/modules/rsocials/mod.ts index 25f9e16..99ff106 100644 --- a/modules/rsocials/mod.ts +++ b/modules/rsocials/mod.ts @@ -1,420 +1,285 @@ /** * Socials module β€” federated social feed aggregator. * - * Aggregates and displays social media activity across community members. - * Supports ActivityPub, RSS, and manual link sharing. + * Slim mod.ts: Automerge doc management, image API routes, + * page routes (injecting web components), seed template, module export. + * + * All UI moved to web components in components/. + * Thread/campaign CRUD handled by Automerge (no REST CRUD). + * File-based threads migrated to Automerge on first access. */ import { resolve } from "node:path"; -import { mkdir, readdir, readFile, writeFile, unlink } from "node:fs/promises"; +import { readdir, readFile } from "node:fs/promises"; import { Hono } from "hono"; +import * as Automerge from "@automerge/automerge"; import { renderShell, renderExternalAppShell, escapeHtml, RICH_LANDING_CSS } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; +import type { SyncServer } from "../../server/local-first/sync-server"; import { renderLanding } from "./landing"; -import { MYCOFI_CAMPAIGN, PLATFORM_ICONS, PLATFORM_COLORS } from "./campaign-data"; +import { MYCOFI_CAMPAIGN } from "./campaign-data"; +import { socialsSchema, socialsDocId, type SocialsDoc, type ThreadData } from "./schemas"; +import { + generateImageFromPrompt, + downloadAndSaveImage, + validateImageFile, + safeExtension, + saveUploadedFile, + deleteImageFile, + deleteOldImage, +} from "./lib/image-gen"; +import { DEMO_FEED } from "./lib/types"; + +let _syncServer: SyncServer | null = null; const routes = new Hono(); -// ── Thread storage helpers ── -const THREADS_BASE = resolve(process.env.FILES_DIR || "./data/files", "threads"); +// ── Automerge doc management ── -interface ThreadData { - id: string; - name: string; - handle: string; - title: string; - tweets: string[]; - imageUrl?: string; - tweetImages?: Record; - createdAt: number; - updatedAt: number; -} - -function generateThreadId(): string { - const random = Math.random().toString(36).substring(2, 8); - return `t-${Date.now()}-${random}`; -} - -async function ensureThreadsDir(): Promise { - await mkdir(THREADS_BASE, { recursive: true }); - return THREADS_BASE; -} - -async function loadThread(id: string): Promise { - try { - const dir = await ensureThreadsDir(); - const raw = await readFile(resolve(dir, `${id}.json`), "utf-8"); - return JSON.parse(raw); - } catch { - return null; +function ensureDoc(space: string): SocialsDoc { + const docId = socialsDocId(space); + let doc = _syncServer!.getDoc(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), "init", (d) => { + const init = socialsSchema.init(); + d.meta = init.meta; + d.meta.spaceSlug = space; + d.threads = {}; + d.campaigns = {}; + }); + _syncServer!.setDoc(docId, doc); } + return doc; } -async function saveThread(data: ThreadData): Promise { - const dir = await ensureThreadsDir(); - await writeFile(resolve(dir, `${data.id}.json`), JSON.stringify(data, null, 2)); +function getThreadFromDoc(space: string, id: string): ThreadData | undefined { + const doc = ensureDoc(space); + return doc.threads?.[id]; } -async function deleteThreadFile(id: string): Promise { +// ── Migration: file-based threads β†’ Automerge ── + +async function migrateFileThreadsToAutomerge(space: string): Promise { + const doc = ensureDoc(space); + if (Object.keys(doc.threads || {}).length > 0) return; // Already has threads + + const threadsDir = resolve(process.env.FILES_DIR || "./data/files", "threads"); + let files: string[]; try { - const dir = await ensureThreadsDir(); - await unlink(resolve(dir, `${id}.json`)); - return true; + files = await readdir(threadsDir); } catch { - return false; + return; // No threads directory } -} -async function listThreads(): Promise> { - const dir = await ensureThreadsDir(); - const files = await readdir(dir); - const threads: Array<{ id: string; title: string; tweetCount: number; updatedAt: number }> = []; + let count = 0; + const docId = socialsDocId(space); for (const f of files) { if (!f.endsWith(".json")) continue; try { - const raw = await readFile(resolve(dir, f), "utf-8"); - const data: ThreadData = JSON.parse(raw); - threads.push({ id: data.id, title: data.title, tweetCount: data.tweets.length, updatedAt: data.updatedAt }); + const raw = await readFile(resolve(threadsDir, f), "utf-8"); + const thread: ThreadData = JSON.parse(raw); + _syncServer!.changeDoc(docId, `migrate thread ${thread.id}`, (d) => { + if (!d.threads) d.threads = {} as any; + d.threads[thread.id] = thread; + }); + count++; } catch { /* skip corrupt files */ } } - threads.sort((a, b) => b.updatedAt - a.updatedAt); - return threads; + + if (count > 0) { + console.log(`[rSocials] Migrated ${count} file-based threads to Automerge for space "${space}"`); + } } -// ── API: Health ── -routes.get("/api/health", (c) => { - return c.json({ ok: true, module: "rsocials" }); -}); +// ── Seed template ── -// ── API: Info ── -routes.get("/api/info", (c) => { - return c.json({ +function seedTemplateSocials(space: string): void { + if (!_syncServer) return; + const doc = ensureDoc(space); + + // Seed MYCOFI_CAMPAIGN if no campaigns exist + if (Object.keys(doc.campaigns || {}).length === 0) { + const docId = socialsDocId(space); + const now = Date.now(); + _syncServer.changeDoc(docId, "seed campaign", (d) => { + if (!d.campaigns) d.campaigns = {} as any; + d.campaigns[MYCOFI_CAMPAIGN.id] = { + ...MYCOFI_CAMPAIGN, + createdAt: now, + updatedAt: now, + }; + }); + } + + // Seed a sample thread if empty + if (Object.keys(doc.threads || {}).length === 0) { + const docId = socialsDocId(space); + const now = Date.now(); + const threadId = `t-${now}-seed`; + _syncServer.changeDoc(docId, "seed thread", (d) => { + if (!d.threads) d.threads = {} as any; + d.threads[threadId] = { + id: threadId, + name: "rSocials", + handle: "@rsocials", + title: "Welcome to Thread Builder", + tweets: [ + "Welcome to the rSocials Thread Builder! Write your thread content here, separated by --- between tweets.", + "Each section becomes a separate tweet card with live character counts and thread numbering.", + "When you're ready, export to Twitter, Bluesky, Mastodon, or LinkedIn. All locally stored, no third-party data mining.", + ], + createdAt: now, + updatedAt: now, + }; + }); + console.log(`[rSocials] Template seeded for "${space}": campaign + sample thread`); + } +} + +// ── API: Health & Info ── + +routes.get("/api/health", (c) => c.json({ ok: true, module: "rsocials" })); + +routes.get("/api/info", (c) => + c.json({ module: "rsocials", description: "Federated social feed aggregator for communities", - features: [ - "ActivityPub integration", - "RSS feed aggregation", - "Link sharing", - "Community timeline", - ], - }); -}); + features: ["ActivityPub integration", "RSS feed aggregation", "Link sharing", "Community timeline"], + }), +); -// ── API: Feed β€” community social timeline ── -routes.get("/api/feed", (c) => { - // Demo feed items - return c.json({ +// ── API: Demo feed ── + +routes.get("/api/feed", (c) => + c.json({ items: [ - { - id: "demo-1", - type: "post", - author: "Alice", - content: "Just published our community governance proposal!", - source: "fediverse", - timestamp: new Date(Date.now() - 3600_000).toISOString(), - likes: 12, - replies: 3, - }, - { - id: "demo-2", - type: "link", - author: "Bob", - content: "Great article on local-first collaboration", - url: "https://example.com/local-first", - source: "shared", - timestamp: new Date(Date.now() - 7200_000).toISOString(), - likes: 8, - replies: 1, - }, - { - id: "demo-3", - type: "post", - author: "Carol", - content: "Welcome new members! Check out rSpace's tools in the app switcher above.", - source: "local", - timestamp: new Date(Date.now() - 14400_000).toISOString(), - likes: 24, - replies: 7, - }, + { id: "demo-1", type: "post", author: "Alice", content: "Just published our community governance proposal!", source: "fediverse", timestamp: new Date(Date.now() - 3600_000).toISOString(), likes: 12, replies: 3 }, + { id: "demo-2", type: "link", author: "Bob", content: "Great article on local-first collaboration", url: "https://example.com/local-first", source: "shared", timestamp: new Date(Date.now() - 7200_000).toISOString(), likes: 8, replies: 1 }, + { id: "demo-3", type: "post", author: "Carol", content: "Welcome new members! Check out rSpace's tools in the app switcher above.", source: "local", timestamp: new Date(Date.now() - 14400_000).toISOString(), likes: 24, replies: 7 }, ], demo: true, - }); -}); + }), +); -// ── API: Campaigns list ── -routes.get("/api/campaigns", (c) => { - const space = c.req.param("space") || "demo"; - const campaign = MYCOFI_CAMPAIGN; - return c.json({ - campaigns: [ - { - id: campaign.id, - title: campaign.title, - description: campaign.description, - duration: campaign.duration, - platforms: campaign.platforms, - postCount: campaign.posts.length, - updated_at: "2026-02-21T09:00:00Z", - url: `/${space}/rsocials/campaign`, - }, - ], - }); -}); - -// ── API: Thread CRUD ── -routes.get("/api/threads", async (c) => { - const threads = await listThreads(); - return c.json({ threads }); -}); - -routes.post("/api/threads", async (c) => { - const { name, handle, title, tweets } = await c.req.json(); - if (!tweets?.length) return c.json({ error: "tweets required" }, 400); - - const id = generateThreadId(); - const now = Date.now(); - const thread: ThreadData = { - id, - name: name || "Your Name", - handle: handle || "@yourhandle", - title: title || (tweets[0] || "").substring(0, 60), - tweets, - createdAt: now, - updatedAt: now, - }; - await saveThread(thread); - return c.json({ id }); -}); - -routes.get("/api/threads/:id", async (c) => { - const id = c.req.param("id"); - if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400); - const thread = await loadThread(id); - if (!thread) return c.json({ error: "Thread not found" }, 404); - return c.json(thread); -}); - -routes.put("/api/threads/:id", async (c) => { - const id = c.req.param("id"); - if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400); - const existing = await loadThread(id); - if (!existing) return c.json({ error: "Thread not found" }, 404); - - const { name, handle, title, tweets, tweetImages } = await c.req.json(); - if (name !== undefined) existing.name = name; - if (handle !== undefined) existing.handle = handle; - if (title !== undefined) existing.title = title; - if (tweets?.length) existing.tweets = tweets; - if (tweetImages !== undefined) existing.tweetImages = tweetImages; - existing.updatedAt = Date.now(); - - await saveThread(existing); - return c.json({ id }); -}); - -routes.delete("/api/threads/:id", async (c) => { - const id = c.req.param("id"); - if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400); - - // Try to delete associated images - const thread = await loadThread(id); - const genDir = resolve(process.env.FILES_DIR || "./data/files", "generated"); - if (thread?.imageUrl) { - const filename = thread.imageUrl.split("/").pop(); - if (filename) { - try { await unlink(resolve(genDir, filename)); } catch {} - } - } - // Delete per-tweet images - if (thread?.tweetImages) { - for (const url of Object.values(thread.tweetImages)) { - const fname = url.split("/").pop(); - if (fname) { - try { await unlink(resolve(genDir, fname)); } catch {} - } - } - } - - const ok = await deleteThreadFile(id); - if (!ok) return c.json({ error: "Thread not found" }, 404); - return c.json({ ok: true }); -}); +// ── Image API routes (server-side, need filesystem + FAL_KEY) ── routes.post("/api/threads/:id/image", async (c) => { + const space = c.req.param("space") || "demo"; const id = c.req.param("id"); if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400); - const thread = await loadThread(id); + const thread = getThreadFromDoc(space, id); if (!thread) return c.json({ error: "Thread not found" }, 404); - const FAL_KEY = process.env.FAL_KEY || ""; - if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503); + if (!process.env.FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503); - // Build prompt from first 2-3 tweets const summary = thread.tweets.slice(0, 3).join(" ").substring(0, 200); const prompt = `Social media thread preview card about: ${summary}. Dark themed, modern, minimal style with abstract shapes.`; - const falRes = await fetch("https://fal.run/fal-ai/flux-pro/v1.1", { - method: "POST", - headers: { - Authorization: `Key ${FAL_KEY}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - prompt, - image_size: "landscape_4_3", - num_images: 1, - safety_tolerance: "2", - }), - }); + const cdnUrl = await generateImageFromPrompt(prompt); + if (!cdnUrl) return c.json({ error: "Image generation failed" }, 502); - if (!falRes.ok) { - console.error("[thread-image] fal.ai error:", await falRes.text()); - return c.json({ error: "Image generation failed" }, 502); - } - - const falData = await falRes.json() as { images?: { url: string }[]; output?: { url: string } }; - const cdnUrl = falData.images?.[0]?.url || falData.output?.url; - if (!cdnUrl) return c.json({ error: "No image returned" }, 502); - - // Download and save locally - const imgRes = await fetch(cdnUrl); - if (!imgRes.ok) return c.json({ error: "Failed to download image" }, 502); - - const imgBuffer = await imgRes.arrayBuffer(); - const genDir = resolve(process.env.FILES_DIR || "./data/files", "generated"); - await mkdir(genDir, { recursive: true }); const filename = `thread-${id}.png`; - await writeFile(resolve(genDir, filename), Buffer.from(imgBuffer)); + const imageUrl = await downloadAndSaveImage(cdnUrl, filename); + if (!imageUrl) return c.json({ error: "Failed to download image" }, 502); - const imageUrl = `/data/files/generated/${filename}`; - thread.imageUrl = imageUrl; - thread.updatedAt = Date.now(); - await saveThread(thread); + // Update Automerge doc with image URL + const docId = socialsDocId(space); + _syncServer!.changeDoc(docId, "set thread image", (d) => { + if (d.threads?.[id]) { + d.threads[id].imageUrl = imageUrl; + d.threads[id].updatedAt = Date.now(); + } + }); return c.json({ imageUrl }); }); routes.post("/api/threads/:id/upload-image", async (c) => { + const space = c.req.param("space") || "demo"; const id = c.req.param("id"); if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400); - const thread = await loadThread(id); + const thread = getThreadFromDoc(space, id); if (!thread) return c.json({ error: "Thread not found" }, 404); let formData: FormData; - try { - formData = await c.req.formData(); - } catch { - return c.json({ error: "Invalid form data" }, 400); - } + try { formData = await c.req.formData(); } catch { return c.json({ error: "Invalid form data" }, 400); } const file = formData.get("file"); if (!file || !(file instanceof File)) return c.json({ error: "No file provided" }, 400); - const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"]; - if (!ALLOWED_TYPES.includes(file.type)) { - return c.json({ error: "Invalid file type. Allowed: png, jpg, webp, gif" }, 400); - } + const err = validateImageFile(file); + if (err) return c.json({ error: err }, 400); - const MAX_SIZE = 5 * 1024 * 1024; // 5MB - if (file.size > MAX_SIZE) { - return c.json({ error: "File too large. Maximum 5MB" }, 400); - } + const ext = safeExtension(file.name); + const filename = `thread-${id}.${ext}`; - const ext = file.name.split(".").pop()?.toLowerCase() || "png"; - const safeExt = ["png", "jpg", "jpeg", "webp", "gif"].includes(ext) ? ext : "png"; - const filename = `thread-${id}.${safeExt}`; - - const genDir = resolve(process.env.FILES_DIR || "./data/files", "generated"); - await mkdir(genDir, { recursive: true }); - - // Delete old image if it exists with a different extension - if (thread.imageUrl) { - const oldFilename = thread.imageUrl.split("/").pop(); - if (oldFilename && oldFilename !== filename) { - try { await unlink(resolve(genDir, oldFilename)); } catch {} - } - } + await deleteOldImage(thread.imageUrl, filename); const buffer = Buffer.from(await file.arrayBuffer()); - await writeFile(resolve(genDir, filename), buffer); + const imageUrl = await saveUploadedFile(buffer, filename); - const imageUrl = `/data/files/generated/${filename}`; - thread.imageUrl = imageUrl; - thread.updatedAt = Date.now(); - await saveThread(thread); + const docId = socialsDocId(space); + _syncServer!.changeDoc(docId, "upload thread image", (d) => { + if (d.threads?.[id]) { + d.threads[id].imageUrl = imageUrl; + d.threads[id].updatedAt = Date.now(); + } + }); return c.json({ imageUrl }); }); -// ── Per-tweet image endpoints ── - routes.post("/api/threads/:id/tweet/:index/upload-image", async (c) => { + const space = c.req.param("space") || "demo"; const id = c.req.param("id"); const index = c.req.param("index"); if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400); if (!/^\d+$/.test(index)) return c.json({ error: "Invalid index" }, 400); - const thread = await loadThread(id); + const thread = getThreadFromDoc(space, id); if (!thread) return c.json({ error: "Thread not found" }, 404); let formData: FormData; - try { - formData = await c.req.formData(); - } catch { - return c.json({ error: "Invalid form data" }, 400); - } + try { formData = await c.req.formData(); } catch { return c.json({ error: "Invalid form data" }, 400); } const file = formData.get("file"); if (!file || !(file instanceof File)) return c.json({ error: "No file provided" }, 400); - const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"]; - if (!ALLOWED_TYPES.includes(file.type)) { - return c.json({ error: "Invalid file type. Allowed: png, jpg, webp, gif" }, 400); - } + const err = validateImageFile(file); + if (err) return c.json({ error: err }, 400); - const MAX_SIZE = 5 * 1024 * 1024; - if (file.size > MAX_SIZE) { - return c.json({ error: "File too large. Maximum 5MB" }, 400); - } + const ext = safeExtension(file.name); + const filename = `thread-${id}-tweet-${index}.${ext}`; - const ext = file.name.split(".").pop()?.toLowerCase() || "png"; - const safeExt = ["png", "jpg", "jpeg", "webp", "gif"].includes(ext) ? ext : "png"; - const filename = `thread-${id}-tweet-${index}.${safeExt}`; - - const genDir = resolve(process.env.FILES_DIR || "./data/files", "generated"); - await mkdir(genDir, { recursive: true }); - - // Delete old image at this index if replacing - if (!thread.tweetImages) thread.tweetImages = {}; - const oldUrl = thread.tweetImages[index]; - if (oldUrl) { - const oldFilename = oldUrl.split("/").pop(); - if (oldFilename && oldFilename !== filename) { - try { await unlink(resolve(genDir, oldFilename)); } catch {} - } - } + const oldUrl = thread.tweetImages?.[index]; + if (oldUrl) await deleteOldImage(oldUrl, filename); const buffer = Buffer.from(await file.arrayBuffer()); - await writeFile(resolve(genDir, filename), buffer); + const imageUrl = await saveUploadedFile(buffer, filename); - const imageUrl = `/data/files/generated/${filename}`; - thread.tweetImages[index] = imageUrl; - thread.updatedAt = Date.now(); - await saveThread(thread); + const docId = socialsDocId(space); + _syncServer!.changeDoc(docId, "upload tweet image", (d) => { + if (d.threads?.[id]) { + if (!d.threads[id].tweetImages) d.threads[id].tweetImages = {} as any; + d.threads[id].tweetImages![index] = imageUrl; + d.threads[id].updatedAt = Date.now(); + } + }); return c.json({ imageUrl }); }); routes.post("/api/threads/:id/tweet/:index/image", async (c) => { + const space = c.req.param("space") || "demo"; const id = c.req.param("id"); const index = c.req.param("index"); if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400); if (!/^\d+$/.test(index)) return c.json({ error: "Invalid index" }, 400); - const thread = await loadThread(id); + const thread = getThreadFromDoc(space, id); if (!thread) return c.json({ error: "Thread not found" }, 404); const tweetIndex = parseInt(index, 10); @@ -422,146 +287,197 @@ routes.post("/api/threads/:id/tweet/:index/image", async (c) => { return c.json({ error: "Tweet index out of range" }, 400); } - const FAL_KEY = process.env.FAL_KEY || ""; - if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503); + if (!process.env.FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503); const tweetText = thread.tweets[tweetIndex].substring(0, 200); const prompt = `Social media post image about: ${tweetText}. Dark themed, modern, minimal style with abstract shapes.`; - const falRes = await fetch("https://fal.run/fal-ai/flux-pro/v1.1", { - method: "POST", - headers: { - Authorization: `Key ${FAL_KEY}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - prompt, - image_size: "landscape_4_3", - num_images: 1, - safety_tolerance: "2", - }), - }); + const cdnUrl = await generateImageFromPrompt(prompt); + if (!cdnUrl) return c.json({ error: "Image generation failed" }, 502); - if (!falRes.ok) { - console.error("[tweet-image] fal.ai error:", await falRes.text()); - return c.json({ error: "Image generation failed" }, 502); - } - - const falData = await falRes.json() as { images?: { url: string }[]; output?: { url: string } }; - const cdnUrl = falData.images?.[0]?.url || falData.output?.url; - if (!cdnUrl) return c.json({ error: "No image returned" }, 502); - - const imgRes = await fetch(cdnUrl); - if (!imgRes.ok) return c.json({ error: "Failed to download image" }, 502); - - const imgBuffer = await imgRes.arrayBuffer(); - const genDir = resolve(process.env.FILES_DIR || "./data/files", "generated"); - await mkdir(genDir, { recursive: true }); const filename = `thread-${id}-tweet-${index}.png`; - // Delete old image at this index if replacing - if (!thread.tweetImages) thread.tweetImages = {}; - const oldUrl = thread.tweetImages[index]; - if (oldUrl) { - const oldFilename = oldUrl.split("/").pop(); - if (oldFilename && oldFilename !== filename) { - try { await unlink(resolve(genDir, oldFilename)); } catch {} + const oldUrl = thread.tweetImages?.[index]; + if (oldUrl) await deleteOldImage(oldUrl, filename); + + const imageUrl = await downloadAndSaveImage(cdnUrl, filename); + if (!imageUrl) return c.json({ error: "Failed to download image" }, 502); + + const docId = socialsDocId(space); + _syncServer!.changeDoc(docId, "generate tweet image", (d) => { + if (d.threads?.[id]) { + if (!d.threads[id].tweetImages) d.threads[id].tweetImages = {} as any; + d.threads[id].tweetImages![index] = imageUrl; + d.threads[id].updatedAt = Date.now(); } - } - - await writeFile(resolve(genDir, filename), Buffer.from(imgBuffer)); - - const imageUrl = `/data/files/generated/${filename}`; - thread.tweetImages[index] = imageUrl; - thread.updatedAt = Date.now(); - await saveThread(thread); + }); return c.json({ imageUrl }); }); routes.delete("/api/threads/:id/tweet/:index/image", async (c) => { + const space = c.req.param("space") || "demo"; const id = c.req.param("id"); const index = c.req.param("index"); if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400); if (!/^\d+$/.test(index)) return c.json({ error: "Invalid index" }, 400); - const thread = await loadThread(id); + const thread = getThreadFromDoc(space, id); if (!thread) return c.json({ error: "Thread not found" }, 404); if (!thread.tweetImages?.[index]) return c.json({ ok: true }); - const url = thread.tweetImages[index]; - const fname = url.split("/").pop(); - if (fname) { - const genDir = resolve(process.env.FILES_DIR || "./data/files", "generated"); - try { await unlink(resolve(genDir, fname)); } catch {} - } + await deleteImageFile(thread.tweetImages[index]); - delete thread.tweetImages[index]; - if (Object.keys(thread.tweetImages).length === 0) delete thread.tweetImages; - thread.updatedAt = Date.now(); - await saveThread(thread); + const docId = socialsDocId(space); + _syncServer!.changeDoc(docId, "remove tweet image", (d) => { + if (d.threads?.[id]?.tweetImages?.[index]) { + delete d.threads[id].tweetImages![index]; + if (Object.keys(d.threads[id].tweetImages || {}).length === 0) { + delete d.threads[id].tweetImages; + } + d.threads[id].updatedAt = Date.now(); + } + }); return c.json({ ok: true }); }); -// ── Demo feed data (server-rendered, no API calls) ── -const DEMO_FEED = [ - { - username: "@alice", - initial: "A", - color: "#6366f1", - content: "Just deployed the new rFlows river view! The enoughness score is such a powerful concept. \u{1F30A}", - timeAgo: "2 hours ago", - likes: 5, - replies: 2, - }, - { - username: "@bob", - initial: "B", - color: "#f59e0b", - content: "Workshop recording is up on rTube: 'Introduction to Local-First Data'. Check it out!", - timeAgo: "5 hours ago", - likes: 8, - replies: 4, - }, - { - username: "@carol", - initial: "C", - color: "#10b981", - content: "The cosmolocal print network now has 6 providers across 4 countries. Design global, manufacture local! \u{1F30D}", - timeAgo: "1 day ago", - likes: 12, - replies: 3, - }, - { - username: "@diana", - initial: "D", - color: "#ec4899", - content: "Reading Elinor Ostrom's 'Governing the Commons' \u2014 so many parallels to what we're building with rSpace governance.", - timeAgo: "1 day ago", - likes: 7, - replies: 5, - }, - { - username: "@eve", - initial: "E", - color: "#14b8a6", - content: "New community garden plot assignments are up on rChoices. Vote for your preferred plot by Friday!", - timeAgo: "2 days ago", - likes: 3, - replies: 1, - }, - { - username: "@frank", - initial: "F", - color: "#8b5cf6", - content: "Mesh network node #42 is online! Coverage now extends to the community center. \u{1F4E1}", - timeAgo: "3 days ago", - likes: 15, - replies: 6, - }, -]; +routes.delete("/api/threads/:id/images", async (c) => { + const space = c.req.param("space") || "demo"; + const id = c.req.param("id"); + if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400); + + const thread = getThreadFromDoc(space, id); + if (!thread) return c.json({ ok: true }); // Thread already gone + + // Clean up header image + if (thread.imageUrl) await deleteImageFile(thread.imageUrl); + + // Clean up per-tweet images + if (thread.tweetImages) { + for (const url of Object.values(thread.tweetImages)) { + await deleteImageFile(url); + } + } + + return c.json({ ok: true }); +}); + +// ── Page routes (inject web components) ── + +routes.get("/campaign", (c) => { + const space = c.req.param("space") || "demo"; + return c.html(renderShell({ + title: `Campaign β€” rSocials | rSpace`, + moduleId: "rsocials", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "dark", + body: ``, + styles: ``, + scripts: ``, + })); +}); + +routes.get("/thread/:id", async (c) => { + const space = c.req.param("space") || "demo"; + const id = c.req.param("id"); + if (!id || id.includes("..") || id.includes("/")) return c.text("Not found", 404); + + const thread = getThreadFromDoc(space, id); + if (!thread) return c.text("Thread not found", 404); + + // OG tags for social crawlers (SSR) + const desc = escapeHtml((thread.tweets[0] || "").substring(0, 200)); + const titleText = escapeHtml(`Thread by ${thread.handle}`); + const origin = "https://rspace.online"; + + let ogHead = ` + + + + + + `; + if (thread.imageUrl) { + ogHead += ` + + `; + } + + // Hydrate thread data for the component + const dataScript = ``; + + return c.html(renderShell({ + title: `${thread.title || "Thread"} β€” rSocials | rSpace`, + moduleId: "rsocials", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "dark", + body: `${dataScript}`, + styles: ``, + scripts: ``, + head: ogHead, + })); +}); + +routes.get("/thread/:id/edit", async (c) => { + const space = c.req.param("space") || "demo"; + const id = c.req.param("id"); + if (!id || id.includes("..") || id.includes("/")) return c.text("Not found", 404); + + const thread = getThreadFromDoc(space, id); + if (!thread) return c.text("Thread not found", 404); + + const dataScript = ``; + + return c.html(renderShell({ + title: `Edit: ${thread.title || "Thread"} β€” rSocials | rSpace`, + moduleId: "rsocials", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "dark", + body: `${dataScript}`, + styles: ``, + scripts: ``, + })); +}); + +routes.get("/thread", (c) => { + const space = c.req.param("space") || "demo"; + return c.html(renderShell({ + title: `Thread Builder β€” rSocials | rSpace`, + moduleId: "rsocials", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "dark", + body: ``, + styles: ``, + scripts: ``, + })); +}); + +routes.get("/threads", (c) => { + const space = c.req.param("space") || "demo"; + return c.html(renderShell({ + title: `Threads β€” rSocials | rSpace`, + moduleId: "rsocials", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "dark", + body: ``, + styles: ``, + scripts: ``, + })); +}); + +routes.get("/campaigns", (c) => { + const space = c.req.param("space") || "demo"; + return c.redirect(`/${space}/rsocials/campaign`); +}); + +// ── Demo feed rendering (server-rendered, no web component needed) ── function renderDemoFeedHTML(): string { const cards = DEMO_FEED.map( @@ -591,1364 +507,13 @@ function renderDemoFeedHTML(): string {

A preview of your community's social timeline

-
- ${cards} -
+
${cards}

This is demo data. Connect ActivityPub or RSS feeds in your own space.

`; } -// ── Campaign page route ── -function renderCampaignPage(space: string): string { - const c = MYCOFI_CAMPAIGN; - const phases = [1, 2, 3]; - const phaseIcons = ["πŸ“£", "πŸš€", "πŸ“‘"]; +// ── Main page route ── - const phaseHTML = phases.map((phaseNum, i) => { - const phasePosts = c.posts.filter((p) => p.phase === phaseNum); - const phaseInfo = c.phases[i]; - const postsHTML = phasePosts.map((post) => { - const icon = PLATFORM_ICONS[post.platform] || post.platform; - const color = PLATFORM_COLORS[post.platform] || "#64748b"; - const statusClass = post.status === "scheduled" ? "campaign-status--scheduled" : "campaign-status--draft"; - const date = new Date(post.scheduledAt); - const dateStr = date.toLocaleDateString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }); - const contentPreview = escapeHtml(post.content.length > 180 ? post.content.substring(0, 180) + "..." : post.content); - const tags = post.hashtags.map((h) => `#${escapeHtml(h)}`).join(" "); - - return ` -
-
- ${icon} - - ${escapeHtml(post.status)} -
-
Step ${post.stepNumber}
-

${contentPreview.replace(/\n/g, "
")}

- -
`; - }).join("\n"); - - return ` -
-

${phaseIcons[i]} Phase ${phaseNum}: ${escapeHtml(phaseInfo.label)} ${escapeHtml(phaseInfo.days)}

-
${postsHTML}
-
`; - }).join("\n"); - - return ` -
-
- πŸ„ -
-

${escapeHtml(c.title)}

-

${escapeHtml(c.description)}

-
- πŸ“… ${escapeHtml(c.duration)} - πŸ“± ${c.platforms.join(", ")} - πŸ“ ${c.posts.length} posts across ${c.phases.length} phases -
-
-
-
- Open Thread Builder - -
- ${phaseHTML} -
-
- - `; -} - -const CAMPAIGN_CSS = ` -.campaign-page { max-width: 900px; margin: 0 auto; padding: 2rem 1rem; } -.campaign-page__header { display: flex; align-items: flex-start; gap: 1rem; margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid var(--rs-input-border); } -.campaign-page__icon { font-size: 3rem; } -.campaign-page__title { margin: 0; font-size: 1.5rem; color: var(--rs-text-primary); } -.campaign-page__desc { margin: 0.25rem 0 0.5rem; color: var(--rs-text-secondary); font-size: 0.9rem; line-height: 1.5; } -.campaign-page__stats { display: flex; flex-wrap: wrap; gap: 1rem; font-size: 0.8rem; color: var(--rs-text-muted); } -.campaign-phase { margin-bottom: 2rem; } -.campaign-phase__title { font-size: 1.15rem; color: var(--rs-text-primary); margin: 0 0 1rem; display: flex; align-items: center; gap: 0.5rem; } -.campaign-phase__days { font-size: 0.8rem; color: var(--rs-text-muted); font-weight: 400; } -.campaign-phase__posts { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.75rem; } -.campaign-post { - background: var(--rs-bg-surface); border: 1px solid var(--rs-input-border); border-radius: 0.75rem; padding: 1rem; - transition: border-color 0.15s; -} -.campaign-post:hover { border-color: #6366f1; } -.campaign-post__header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; } -.campaign-post__platform { - width: 28px; height: 28px; border-radius: 6px; display: flex; align-items: center; justify-content: center; - color: white; font-size: 0.75rem; font-weight: 700; flex-shrink: 0; -} -.campaign-post__meta { flex: 1; min-width: 0; } -.campaign-post__meta strong { display: block; font-size: 0.8rem; color: var(--rs-text-primary); text-transform: capitalize; } -.campaign-post__date { font-size: 0.7rem; color: var(--rs-text-muted); } -.campaign-post__step { font-size: 0.65rem; color: #6366f1; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; } -.campaign-status { font-size: 0.6rem; padding: 2px 6px; border-radius: 4px; text-transform: uppercase; font-weight: 600; letter-spacing: 0.05em; white-space: nowrap; } -.campaign-status--scheduled { background: rgba(16,185,129,0.15); color: #34d399; } -.campaign-status--draft { background: rgba(251,191,36,0.15); color: #fbbf24; } -.campaign-post__content { font-size: 0.8rem; color: var(--rs-text-secondary); line-height: 1.5; margin: 0 0 0.5rem; } -.campaign-post__tags { display: flex; flex-wrap: wrap; gap: 0.25rem; } -.campaign-tag { font-size: 0.65rem; color: #7dd3fc; } -.campaign-page__actions { display: flex; gap: 0.75rem; margin-bottom: 1.5rem; } -.campaign-action-btn { - padding: 0.5rem 1rem; border-radius: 8px; font-size: 0.85rem; font-weight: 600; - cursor: pointer; transition: all 0.15s; text-decoration: none; display: inline-flex; align-items: center; -} -.campaign-action-btn--primary { background: #6366f1; color: white; border: none; } -.campaign-action-btn--primary:hover { background: #818cf8; } -.campaign-action-btn--outline { background: transparent; color: var(--rs-text-secondary); border: 1px solid var(--rs-input-border); } -.campaign-action-btn--outline:hover { border-color: #6366f1; color: #c4b5fd; } -.campaign-modal-overlay { - position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; - align-items: center; justify-content: center; z-index: 1000; -} -.campaign-modal-overlay[hidden] { display: none; } -.campaign-modal { - background: var(--rs-bg-surface); border: 1px solid var(--rs-input-border); border-radius: 0.75rem; - padding: 1.5rem; width: 90%; max-width: 540px; display: flex; flex-direction: column; gap: 1rem; -} -.campaign-modal__header { display: flex; align-items: center; justify-content: space-between; } -.campaign-modal__header h3 { margin: 0; font-size: 1.1rem; color: var(--rs-text-primary); } -.campaign-modal__close { - background: none; border: none; color: var(--rs-text-muted); font-size: 1.5rem; cursor: pointer; - line-height: 1; padding: 0; -} -.campaign-modal__close:hover { color: var(--rs-text-primary); } -.campaign-modal__textarea { - width: 100%; min-height: 200px; background: var(--rs-input-bg); color: var(--rs-input-text); border: 1px solid var(--rs-input-border); - border-radius: 8px; padding: 0.75rem; font-family: inherit; font-size: 0.85rem; resize: vertical; - line-height: 1.5; box-sizing: border-box; -} -.campaign-modal__textarea:focus { outline: none; border-color: #6366f1; } -.campaign-modal__textarea::placeholder { color: var(--rs-text-muted); } -.campaign-modal__row { display: flex; gap: 0.75rem; align-items: center; } -.campaign-modal__select { - flex: 1; background: var(--rs-input-bg); color: var(--rs-input-text); border: 1px solid var(--rs-input-border); - border-radius: 8px; padding: 0.5rem 0.75rem; font-size: 0.85rem; -} -.campaign-modal__select:focus { outline: none; border-color: #6366f1; } -`; - -routes.get("/campaign", (c) => { - const space = c.req.param("space") || "demo"; - return c.html(renderShell({ - title: `Campaign β€” rSocials | rSpace`, - moduleId: "rsocials", - spaceSlug: space, - modules: getModuleInfoList(), - theme: "dark", - body: renderCampaignPage(space), - styles: ``, - })); -}); - -// ── Thread Builder ── -const THREAD_CSS = ` -.thread-page { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; max-width: 1100px; margin: 0 auto; padding: 2rem 1rem; min-height: 80vh; } -.thread-page__header { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0.5rem; } -.thread-page__header h1 { margin: 0; font-size: 1.5rem; color: var(--rs-text-primary); background: linear-gradient(135deg, #7dd3fc, #c4b5fd); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } -.thread-page__actions { display: flex; gap: 0.5rem; flex-wrap: wrap; } -.thread-btn { padding: 0.5rem 1rem; border-radius: 8px; border: none; font-size: 0.85rem; font-weight: 600; cursor: pointer; transition: all 0.15s; } -.thread-btn--primary { background: #6366f1; color: white; } -.thread-btn--primary:hover { background: #818cf8; } -.thread-btn--outline { background: transparent; color: var(--rs-text-secondary); border: 1px solid var(--rs-input-border); } -.thread-btn--outline:hover { border-color: #6366f1; color: #c4b5fd; } -.thread-btn--success { background: #10b981; color: white; } -.thread-btn--success:hover { background: #34d399; } -.thread-btn:disabled { opacity: 0.5; cursor: not-allowed; } -.thread-compose { position: sticky; top: 1rem; align-self: start; display: flex; flex-direction: column; gap: 1rem; } -.thread-compose__textarea { - width: 100%; min-height: 320px; background: var(--rs-bg-surface); color: var(--rs-text-primary); border: 1px solid var(--rs-input-border); - border-radius: 0.75rem; padding: 1rem; font-family: inherit; font-size: 0.9rem; resize: vertical; - line-height: 1.6; box-sizing: border-box; -} -.thread-compose__textarea:focus { outline: none; border-color: #6366f1; } -.thread-compose__textarea::placeholder { color: var(--rs-text-muted); } -.thread-compose__fields { display: flex; gap: 0.75rem; } -.thread-compose__input { - flex: 1; background: var(--rs-bg-surface); color: var(--rs-text-primary); border: 1px solid var(--rs-input-border); - border-radius: 8px; padding: 0.5rem 0.75rem; font-size: 0.85rem; box-sizing: border-box; -} -.thread-compose__input:focus { outline: none; border-color: #6366f1; } -.thread-compose__input::placeholder { color: var(--rs-text-muted); } -.thread-compose__title { - width: 100%; background: var(--rs-bg-surface); color: var(--rs-text-primary); border: 1px solid var(--rs-input-border); - border-radius: 8px; padding: 0.5rem 0.75rem; font-size: 0.85rem; box-sizing: border-box; -} -.thread-compose__title:focus { outline: none; border-color: #6366f1; } -.thread-compose__title::placeholder { color: var(--rs-text-muted); } -.thread-drafts { grid-column: 1 / -1; } -.thread-drafts__toggle { cursor: pointer; user-select: none; } -.thread-drafts__list { - display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 0.5rem; - margin-top: 0.75rem; -} -.thread-drafts__list[hidden] { display: none; } -.thread-drafts__empty { color: var(--rs-text-muted); font-size: 0.8rem; padding: 0.5rem 0; } -.thread-draft-item { - display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; - background: var(--rs-bg-surface); border: 1px solid var(--rs-input-border); border-radius: 8px; - transition: border-color 0.15s; cursor: pointer; -} -.thread-draft-item:hover { border-color: #6366f1; } -.thread-draft-item--active { border-color: #6366f1; background: rgba(99,102,241,0.1); } -.thread-draft-item__info { flex: 1; min-width: 0; } -.thread-draft-item__info strong { display: block; font-size: 0.8rem; color: var(--rs-text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.thread-draft-item__info span { font-size: 0.7rem; color: var(--rs-text-muted); } -.thread-draft-item__delete { - background: none; border: none; color: var(--rs-text-muted); font-size: 1.2rem; cursor: pointer; - padding: 0 4px; line-height: 1; flex-shrink: 0; -} -.thread-draft-item__delete:hover { color: #ef4444; } -.thread-image-section { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; } -.thread-image-preview { border-radius: 8px; overflow: hidden; border: 1px solid var(--rs-input-border); } -.thread-image-preview[hidden] { display: none; } -.thread-image-preview img { display: block; max-width: 200px; height: auto; } -#share-link-area { grid-column: 1 / -1; } -.thread-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; -} -.thread-share-link code { font-size: 0.75rem; color: #7dd3fc; } -.thread-share-link button { - background: none; border: none; color: var(--rs-text-secondary); cursor: pointer; font-size: 0.75rem; padding: 2px 6px; -} -.thread-share-link button:hover { color: var(--rs-text-primary); } -.thread-preview { display: flex; flex-direction: column; gap: 0; } -.thread-preview__empty { color: var(--rs-text-muted); text-align: center; padding: 3rem 1rem; font-size: 0.9rem; } -.tweet-card { - position: relative; background: var(--rs-bg-surface); border: 1px solid var(--rs-input-border); 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; } -.tweet-card__connector { - position: absolute; left: 29px; top: -1px; width: 2px; height: 1rem; - background: var(--rs-input-border); z-index: 1; -} -.tweet-card__header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; } -.tweet-card__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; -} -.tweet-card__name { font-weight: 700; color: var(--rs-text-primary); font-size: 0.9rem; } -.tweet-card__handle { color: var(--rs-text-muted); font-size: 0.85rem; } -.tweet-card__dot { color: var(--rs-text-muted); font-size: 0.85rem; } -.tweet-card__time { color: var(--rs-text-muted); font-size: 0.85rem; } -.tweet-card__content { color: var(--rs-text-primary); font-size: 0.95rem; line-height: 1.6; margin: 0 0 0.75rem; white-space: pre-wrap; word-break: break-word; } -.tweet-card__footer { display: flex; align-items: center; justify-content: space-between; } -.tweet-card__actions { display: flex; gap: 1.25rem; } -.tweet-card__action { display: flex; align-items: center; gap: 0.3rem; color: var(--rs-text-muted); font-size: 0.8rem; cursor: default; } -.tweet-card__action svg { width: 16px; height: 16px; } -.tweet-card__meta { display: flex; align-items: center; gap: 0.75rem; font-size: 0.75rem; color: var(--rs-text-muted); } -.tweet-card__chars { font-variant-numeric: tabular-nums; } -.tweet-card__chars--over { color: #ef4444; font-weight: 600; } -.tweet-card__thread-num { color: #6366f1; font-weight: 600; } -@media (max-width: 700px) { - .thread-page { grid-template-columns: 1fr; } - .thread-compose { position: static; } -} -.thread-export-dropdown { position: relative; } -.thread-export-menu { - position: absolute; top: calc(100% + 4px); right: 0; z-index: 100; - background: var(--rs-bg-surface); border: 1px solid var(--rs-input-border); border-radius: 8px; - min-width: 180px; overflow: hidden; - box-shadow: 0 8px 24px var(--rs-shadow-lg); -} -.thread-export-menu[hidden] { display: none; } -.thread-export-menu button { - display: block; width: 100%; padding: 0.6rem 0.75rem; border: none; - background: transparent; color: var(--rs-text-primary); font-size: 0.85rem; - text-align: left; cursor: pointer; transition: background 0.1s; -} -.thread-export-menu button:hover { background: rgba(99,102,241,0.15); } -.thread-export-menu button + button { border-top: 1px solid var(--rs-bg-hover); } -.tweet-card__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); - background: var(--rs-bg-surface); color: var(--rs-text-muted); 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 .tweet-card__photo-btn { opacity: 1; } -.tweet-card__photo-btn:hover { border-color: #6366f1; color: #c4b5fd; } -.tweet-card__photo-btn svg { width: 14px; height: 14px; } -.tweet-card__photo-btn .photo-btn-plus { - position: absolute; bottom: -1px; right: -3px; font-size: 10px; font-weight: 700; - color: #6366f1; line-height: 1; -} -.tweet-card__photo-menu { - position: absolute; top: 38px; right: 8px; z-index: 10; - background: var(--rs-bg-surface); border: 1px solid var(--rs-input-border); border-radius: 8px; - min-width: 160px; overflow: hidden; box-shadow: 0 8px 24px var(--rs-shadow-lg); -} -.tweet-card__photo-menu[hidden] { display: none; } -.tweet-card__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); font-size: 0.8rem; cursor: pointer; transition: background 0.1s; -} -.tweet-card__photo-menu button:hover { background: rgba(99,102,241,0.15); } -.tweet-card__photo-menu button + button { border-top: 1px solid var(--rs-bg-hover); } -.tweet-card__photo-menu button svg { width: 14px; height: 14px; } -.tweet-card__attached-image { position: relative; margin-top: 0.5rem; border-radius: 8px; overflow: hidden; border: 1px solid var(--rs-input-border); } -.tweet-card__attached-image img { display: block; width: 100%; height: auto; } -.tweet-card__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; -} -.tweet-card__image-remove:hover { background: #ef4444; } -`; - -function renderThreadBuilderPage(space: string, threadData?: ThreadData | null): string { - const dataScript = threadData - ? `` - : ""; - const basePath = `/${space}/rsocials/`; - - return ` - ${dataScript} - -
-
-

Thread Builder

-
- - - -
- - -
-
-
-
- - -
- -
- -
- - -
- -
- - - - -
-
-
-
Your tweet thread preview will appear here
-
- -
- `; -} - -// ── Thread read-only view (shareable permalink) ── -function renderThreadReadOnly(space: string, thread: ThreadData): string { - const name = escapeHtml(thread.name || "Anonymous"); - const handle = escapeHtml(thread.handle || "@anonymous"); - const initial = name.charAt(0).toUpperCase(); - const total = thread.tweets.length; - const dateStr = new Date(thread.createdAt).toLocaleDateString("en-US", { - month: "long", day: "numeric", year: "numeric", - }); - - const tweetCards = thread.tweets.map((text, i) => { - const len = text.length; - const connector = i > 0 ? '
' : ""; - const tweetImgUrl = thread.tweetImages?.[String(i)]; - const tweetImgHtml = tweetImgUrl - ? `
Tweet image
` - : ""; - return `
- ${connector} -
-
${escapeHtml(initial)}
- ${name} - ${handle} - · - ${escapeHtml(dateStr)} -
-

${escapeHtml(text)}

- ${tweetImgHtml} - -
`; - }).join("\n"); - - const imageHTML = thread.imageUrl - ? `
Thread preview
` - : ""; - - return ` -
-
-
-
${escapeHtml(initial)}
-
-
${name}
-
${handle}
-
-
-
- ${total} tweet${total === 1 ? "" : "s"} - · - ${escapeHtml(dateStr)} -
-
- ${thread.title ? `

${escapeHtml(thread.title)}

` : ""} - ${imageHTML} -
- ${tweetCards} -
-
- Edit Thread - - -
- - -
-
- -
- - `; -} - -const THREAD_RO_CSS = ` -.thread-ro { max-width: 640px; margin: 0 auto; padding: 2rem 1rem; } -.thread-ro__header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem; } -.thread-ro__author { display: flex; align-items: center; gap: 0.75rem; } -.thread-ro__name { font-weight: 700; color: var(--rs-text-primary); font-size: 1.1rem; } -.thread-ro__handle { color: var(--rs-text-muted); font-size: 0.9rem; } -.thread-ro__meta { display: flex; align-items: center; gap: 0.5rem; color: var(--rs-text-muted); font-size: 0.85rem; } -.thread-ro__title { font-size: 1.4rem; color: var(--rs-text-primary); margin: 0 0 1.5rem; line-height: 1.3; } -.thread-ro__image { margin-bottom: 1.5rem; border-radius: 12px; overflow: hidden; border: 1px solid var(--rs-input-border); } -.thread-ro__image img { display: block; width: 100%; height: auto; } -.thread-ro__cards { margin-bottom: 1.5rem; } -.thread-ro__actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 2rem; padding-bottom: 2rem; border-bottom: 1px solid var(--rs-input-border); } -.thread-ro__cta { display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap; } -.thread-export-dropdown { position: relative; } -.thread-export-menu { - position: absolute; top: calc(100% + 4px); right: 0; z-index: 100; - background: var(--rs-bg-surface); border: 1px solid var(--rs-input-border); border-radius: 8px; - min-width: 180px; overflow: hidden; - box-shadow: 0 8px 24px var(--rs-shadow-lg); -} -.thread-export-menu[hidden] { display: none; } -.thread-export-menu button { - display: block; width: 100%; padding: 0.6rem 0.75rem; border: none; - background: transparent; color: var(--rs-text-primary); font-size: 0.85rem; - text-align: left; cursor: pointer; transition: background 0.1s; -} -.thread-export-menu button:hover { background: rgba(99,102,241,0.15); } -.thread-export-menu button + button { border-top: 1px solid var(--rs-bg-hover); } -.thread-export-toast { - position: fixed; bottom: 1.5rem; left: 50%; transform: translateX(-50%); - background: var(--rs-bg-surface); 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); z-index: 1000; - transition: opacity 0.2s; -} -.thread-export-toast[hidden] { display: none; } -`; - -// ── Thread read-only permalink with OG tags ── -routes.get("/thread/:id", async (c) => { - const space = c.req.param("space") || "demo"; - const id = c.req.param("id"); - if (!id || id.includes("..") || id.includes("/")) return c.text("Not found", 404); - - const thread = await loadThread(id); - if (!thread) return c.text("Thread not found", 404); - - const desc = escapeHtml((thread.tweets[0] || "").substring(0, 200)); - const titleText = escapeHtml(`Thread by ${thread.handle}`); - const origin = "https://rspace.online"; - - let ogHead = ` - - - - - - `; - - if (thread.imageUrl) { - ogHead += ` - - `; - } - - return c.html(renderShell({ - title: `${thread.title || "Thread"} β€” rSocials | rSpace`, - moduleId: "rsocials", - spaceSlug: space, - modules: getModuleInfoList(), - theme: "dark", - body: renderThreadReadOnly(space, thread), - styles: ``, - head: ogHead, - })); -}); - -// ── Thread editor (edit existing) ── -routes.get("/thread/:id/edit", async (c) => { - const space = c.req.param("space") || "demo"; - const id = c.req.param("id"); - if (!id || id.includes("..") || id.includes("/")) return c.text("Not found", 404); - - const thread = await loadThread(id); - if (!thread) return c.text("Thread not found", 404); - - return c.html(renderShell({ - title: `Edit: ${thread.title || "Thread"} β€” rSocials | rSpace`, - moduleId: "rsocials", - spaceSlug: space, - modules: getModuleInfoList(), - theme: "dark", - body: renderThreadBuilderPage(space, thread), - styles: ``, - })); -}); - -// ── Thread builder (new) ── -routes.get("/thread", (c) => { - const space = c.req.param("space") || "demo"; - return c.html(renderShell({ - title: `Thread Builder β€” rSocials | rSpace`, - moduleId: "rsocials", - spaceSlug: space, - modules: getModuleInfoList(), - theme: "dark", - body: renderThreadBuilderPage(space), - styles: ``, - })); -}); - -// ── Thread listing / gallery ── -const THREADS_LIST_CSS = ` -.threads-gallery { max-width: 900px; margin: 0 auto; padding: 2rem 1rem; } -.threads-gallery__header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 0.75rem; } -.threads-gallery__header h1 { - margin: 0; font-size: 1.5rem; - background: linear-gradient(135deg, #7dd3fc, #c4b5fd); - -webkit-background-clip: text; -webkit-text-fill-color: transparent; -} -.threads-gallery__grid { - display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; -} -.threads-gallery__empty { color: var(--rs-text-muted); text-align: center; padding: 3rem 1rem; font-size: 0.9rem; } -.thread-card { - background: var(--rs-bg-surface); border: 1px solid var(--rs-input-border); border-radius: 0.75rem; - padding: 1.25rem; transition: border-color 0.15s, transform 0.15s; - display: flex; flex-direction: column; gap: 0.75rem; - text-decoration: none; color: inherit; -} -.thread-card:hover { border-color: #6366f1; transform: translateY(-2px); } -.thread-card__title { font-size: 1rem; font-weight: 700; color: var(--rs-text-primary); margin: 0; line-height: 1.3; } -.thread-card__preview { - font-size: 0.85rem; color: var(--rs-text-secondary); line-height: 1.5; - display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; -} -.thread-card__meta { display: flex; align-items: center; gap: 0.75rem; font-size: 0.75rem; color: var(--rs-text-muted); margin-top: auto; } -.thread-card__author { display: flex; align-items: center; gap: 0.4rem; } -.thread-card__avatar-sm { - width: 20px; height: 20px; border-radius: 50%; background: #6366f1; - display: flex; align-items: center; justify-content: center; - color: white; font-weight: 700; font-size: 0.55rem; flex-shrink: 0; -} -.thread-card__image { border-radius: 8px; overflow: hidden; border: 1px solid var(--rs-input-border); margin-bottom: 0.25rem; } -.thread-card__image img { display: block; width: 100%; height: 120px; object-fit: cover; } -`; - -async function renderThreadsGallery(space: string): Promise { - const dir = await ensureThreadsDir(); - const files = await readdir(dir); - const threads: ThreadData[] = []; - - for (const f of files) { - if (!f.endsWith(".json")) continue; - try { - const raw = await readFile(resolve(dir, f), "utf-8"); - threads.push(JSON.parse(raw)); - } catch { /* skip corrupt */ } - } - threads.sort((a, b) => b.updatedAt - a.updatedAt); - - if (!threads.length) { - return ` - `; - } - - const cards = threads.map((t) => { - const initial = (t.name || "?").charAt(0).toUpperCase(); - const preview = escapeHtml((t.tweets[0] || "").substring(0, 200)); - const dateStr = new Date(t.updatedAt).toLocaleDateString("en-US", { month: "short", day: "numeric" }); - const imageTag = t.imageUrl - ? `
` - : ""; - - return ` - ${imageTag} -

${escapeHtml(t.title || "Untitled Thread")}

-

${preview}

-
-
-
${escapeHtml(initial)}
- ${escapeHtml(t.handle || t.name || "Anonymous")} -
- ${t.tweets.length} tweet${t.tweets.length === 1 ? "" : "s"} - ${dateStr} -
-
`; - }).join("\n"); - - return ` - `; -} - -routes.get("/threads", async (c) => { - const space = c.req.param("space") || "demo"; - const body = await renderThreadsGallery(space); - return c.html(renderShell({ - title: `Threads β€” rSocials | rSpace`, - moduleId: "rsocials", - spaceSlug: space, - modules: getModuleInfoList(), - theme: "dark", - body, - styles: ``, - })); -}); - -// ── Campaigns redirect (plural β†’ singular) ── -routes.get("/campaigns", (c) => { - const space = c.req.param("space") || "demo"; - return c.redirect(`/${space}/rsocials/campaign`); -}); - -// ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; const view = c.req.query("view"); @@ -1966,106 +531,41 @@ routes.get("/", (c) => { } const isDemo = space === "demo"; - - const body = isDemo - ? renderDemoFeedHTML() - : renderLanding(); - - const demoFeedStyles = ``; - + const body = isDemo ? renderDemoFeedHTML() : renderLanding(); const styles = isDemo - ? demoFeedStyles + ? `` : ``; - return c.html( - renderShell({ - title: `${space} β€” Socials | rSpace`, - moduleId: "rsocials", - spaceSlug: space, - modules: getModuleInfoList(), - theme: "dark", - body, - styles, - }), - ); + return c.html(renderShell({ + title: `${space} β€” Socials | rSpace`, + moduleId: "rsocials", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "dark", + body, + styles, + })); }); +// ── Module export ── + export const socialsModule: RSpaceModule = { id: "rsocials", name: "rSocials", icon: "πŸ“’", description: "Federated social feed aggregator for communities", - scoping: { defaultScope: 'global', userConfigurable: true }, + scoping: { defaultScope: "global", userConfigurable: true }, + docSchemas: [{ pattern: "{space}:socials:data", description: "Threads and campaigns", init: socialsSchema.init }], routes, publicWrite: true, standaloneDomain: "rsocials.online", landingPage: renderLanding, + seedTemplate: seedTemplateSocials, + async onInit(ctx) { + _syncServer = ctx.syncServer; + // Run migration for any existing file-based threads + try { await migrateFileThreadsToAutomerge("demo"); } catch { /* ignore */ } + }, externalApp: { url: "https://social.jeffemmett.com", name: "Postiz" }, feeds: [ { diff --git a/modules/rsocials/schemas.ts b/modules/rsocials/schemas.ts new file mode 100644 index 0000000..7f0b190 --- /dev/null +++ b/modules/rsocials/schemas.ts @@ -0,0 +1,90 @@ +/** + * rSocials Automerge document schemas. + * + * Granularity: one Automerge document per space. + * DocId format: {space}:socials:data + * + * Images stay on filesystem, referenced by URL strings in the doc. + */ + +import type { DocSchema } from '../../shared/local-first/document'; + +// ── Thread types ── + +export interface ThreadData { + id: string; + name: string; + handle: string; + title: string; + tweets: string[]; + imageUrl?: string; + tweetImages?: Record; + createdAt: number; + updatedAt: number; +} + +// ── Campaign types ── + +export interface CampaignPost { + id: string; + platform: string; + postType: string; + stepNumber: number; + content: string; + scheduledAt: string; + status: string; + hashtags: string[]; + phase: number; + phaseLabel: string; +} + +export interface Campaign { + id: string; + title: string; + description: string; + duration: string; + platforms: string[]; + phases: { name: string; label: string; days: string }[]; + posts: CampaignPost[]; + createdAt: number; + updatedAt: number; +} + +// ── Document root ── + +export interface SocialsDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + threads: Record; + campaigns: Record; +} + +// ── Schema registration ── + +export const socialsSchema: DocSchema = { + module: 'socials', + collection: 'data', + version: 1, + init: (): SocialsDoc => ({ + meta: { + module: 'socials', + collection: 'data', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + threads: {}, + campaigns: {}, + }), +}; + +// ── Helpers ── + +export function socialsDocId(space: string) { + return `${space}:socials:data` as const; +} diff --git a/vite.config.ts b/vite.config.ts index 2c7b004..9752e19 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -568,7 +568,7 @@ export default defineConfig({ resolve(__dirname, "dist/modules/rnetwork/network.css"), ); - // Build socials canvas component + // Build socials thread builder component await build({ configFile: false, root: resolve(__dirname, "modules/rsocials/components"), @@ -576,23 +576,63 @@ export default defineConfig({ emptyOutDir: false, outDir: resolve(__dirname, "dist/modules/rsocials"), lib: { - entry: resolve(__dirname, "modules/rsocials/components/folk-socials-canvas.ts"), + entry: resolve(__dirname, "modules/rsocials/components/folk-thread-builder.ts"), formats: ["es"], - fileName: () => "folk-socials-canvas.js", + fileName: () => "folk-thread-builder.js", }, rollupOptions: { output: { - entryFileNames: "folk-socials-canvas.js", + entryFileNames: "folk-thread-builder.js", }, }, }, }); - // Copy socials canvas CSS + // Build socials thread gallery component + await build({ + configFile: false, + root: resolve(__dirname, "modules/rsocials/components"), + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/rsocials"), + lib: { + entry: resolve(__dirname, "modules/rsocials/components/folk-thread-gallery.ts"), + formats: ["es"], + fileName: () => "folk-thread-gallery.js", + }, + rollupOptions: { + output: { + entryFileNames: "folk-thread-gallery.js", + }, + }, + }, + }); + + // Build socials campaign manager component + await build({ + configFile: false, + root: resolve(__dirname, "modules/rsocials/components"), + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/rsocials"), + lib: { + entry: resolve(__dirname, "modules/rsocials/components/folk-campaign-manager.ts"), + formats: ["es"], + fileName: () => "folk-campaign-manager.js", + }, + rollupOptions: { + output: { + entryFileNames: "folk-campaign-manager.js", + }, + }, + }, + }); + + // Copy socials CSS mkdirSync(resolve(__dirname, "dist/modules/rsocials"), { recursive: true }); copyFileSync( - resolve(__dirname, "modules/rsocials/components/socials-canvas.css"), - resolve(__dirname, "dist/modules/rsocials/socials-canvas.css"), + resolve(__dirname, "modules/rsocials/components/socials.css"), + resolve(__dirname, "dist/modules/rsocials/socials.css"), ); // Build tube module component