rspace-online/modules/rsocials/components/folk-thread-builder.ts

1173 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* <folk-thread-builder> — 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<string, string> = {};
private _autoSaveTimer: ReturnType<typeof setTimeout> | null = null;
private _offlineUnsub: (() => void) | null = null;
private _tweetImageUploadIdx: string | null = null;
// SVG icons
private svgReply = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>';
private svgRetweet = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M17 1l4 4-4 4"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><path d="M7 23l-4-4 4-4"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>';
private svgHeart = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>';
private svgShare = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg>';
private svgUpload = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>';
private svgSparkle = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l2.4 7.2L22 12l-7.6 2.8L12 22l-2.4-7.2L2 12l7.6-2.8z"/></svg>';
private svgCamera = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>';
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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 ? '<div class="connector"></div>' : '';
const tweetImgUrl = t.tweetImages?.[String(i)];
const tweetImgHtml = tweetImgUrl
? `<div class="attached-image"><img src="${this.esc(tweetImgUrl)}" alt="Tweet image"></div>`
: '';
return `<div class="tweet-card">
${connector}
<div class="tc-header">
<div class="tc-avatar">${this.esc(initial)}</div>
<span class="tc-name">${name}</span>
<span class="tc-handle">${handle}</span>
<span class="tc-dot">&#183;</span>
<span class="tc-time">${this.esc(dateStr)}</span>
</div>
<p class="tc-content">${this.esc(text)}</p>
${tweetImgHtml}
<div class="tc-footer">
<div class="tc-actions">
<span class="tc-action">${this.svgReply}</span>
<span class="tc-action">${this.svgRetweet}</span>
<span class="tc-action">${this.svgHeart}</span>
<span class="tc-action">${this.svgShare}</span>
</div>
<div class="tc-meta">
<span class="tc-chars${len > 280 ? ' tc-chars--over' : ''}">${len}/280</span>
<span class="tc-thread-num">${i + 1}/${total}</span>
</div>
</div>
</div>`;
}).join('\n');
const imageHTML = t.imageUrl
? `<div class="ro-image"><img src="${this.esc(t.imageUrl)}" alt="Thread preview"></div>`
: '';
this.shadowRoot.innerHTML = `
<style>${this.getBaseStyles()}${this.getReadonlyStyles()}</style>
<div class="thread-ro">
<div class="ro-header">
<div class="ro-author">
<div class="tc-avatar" style="width:48px;height:48px;font-size:1.2rem">${this.esc(initial)}</div>
<div>
<div class="ro-name">${name}</div>
<div class="ro-handle">${handle}</div>
</div>
</div>
<div class="ro-meta">
<span>${total} tweet${total === 1 ? '' : 's'}</span>
<span>&#183;</span>
<span>${this.esc(dateStr)}</span>
</div>
</div>
${t.title ? `<h1 class="ro-title">${this.esc(t.title)}</h1>` : ''}
${imageHTML}
<div class="preview ro-cards">${tweetCards}</div>
<div class="ro-actions">
<a href="/${this.esc(this._space)}/rsocials/thread/${this.esc(t.id)}/edit" class="btn btn--primary">Edit Thread</a>
<button class="btn btn--outline" id="ro-copy-thread">Copy Thread</button>
<button class="btn btn--outline" id="ro-copy-link">Copy Link</button>
<div class="export-dropdown">
<button class="btn btn--outline" id="ro-export-btn">Export &#9662;</button>
<div class="export-menu" id="ro-export-menu" hidden>
<button data-platform="twitter">𝕏 Twitter (280)</button>
<button data-platform="bluesky">🦋 Bluesky (300)</button>
<button data-platform="mastodon">🐘 Mastodon (500)</button>
<button data-platform="linkedin">💼 LinkedIn</button>
<button data-platform="plain">📄 Plain Text</button>
</div>
</div>
</div>
<div class="ro-cta">
<a href="/${this.esc(this._space)}/rsocials/thread" class="btn btn--success">Create Your Own Thread</a>
<a href="/${this.esc(this._space)}/rsocials/threads" class="btn btn--outline">Browse All Threads</a>
</div>
</div>
<div class="toast" id="export-toast" hidden></div>
`;
this.bindReadonlyEvents();
}
private renderEditor() {
if (!this.shadowRoot) return;
const t = this._thread;
this.shadowRoot.innerHTML = `
<style>${this.getBaseStyles()}${this.getEditorStyles()}</style>
<div class="thread-page">
<div class="page-header">
<h1>Thread Builder</h1>
<div class="page-actions">
<button class="btn btn--primary" id="thread-save">Save Draft</button>
<button class="btn btn--success" id="thread-share">Share</button>
<button class="btn btn--outline" id="thread-copy">Copy Thread</button>
<div class="export-dropdown">
<button class="btn btn--outline" id="thread-export-btn">Export &#9662;</button>
<div class="export-menu" id="thread-export-menu" hidden>
<button data-platform="twitter">𝕏 Twitter (280)</button>
<button data-platform="bluesky">🦋 Bluesky (300)</button>
<button data-platform="mastodon">🐘 Mastodon (500)</button>
<button data-platform="linkedin">💼 LinkedIn</button>
<button data-platform="plain">📄 Plain Text</button>
</div>
</div>
</div>
</div>
<div class="drafts-area">
<button class="btn btn--outline drafts-toggle" id="toggle-drafts">Saved Drafts &#9662;</button>
<div class="drafts-list" id="drafts-list" hidden></div>
</div>
<div id="share-link-area"></div>
<div class="compose">
<textarea class="compose-textarea" id="thread-input" placeholder="Write your tweets here, separated by ---\n\nExample:\nFirst tweet goes here\n---\nSecond tweet\n---\nThird tweet">${t ? this.esc(t.tweets.join('\n---\n')) : ''}</textarea>
<div class="compose-fields">
<input class="compose-input" id="thread-name" placeholder="Display name" value="${this.esc(t?.name || 'Your Name')}">
<input class="compose-input" id="thread-handle" placeholder="@handle" value="${this.esc(t?.handle || '@yourhandle')}">
</div>
<input class="compose-title" id="thread-title" placeholder="Thread title (defaults to first tweet)" value="${this.esc(t?.title || '')}">
<div class="image-section">
<input type="file" id="upload-image-input" accept="image/png,image/jpeg,image/webp,image/gif" hidden>
<button class="btn btn--outline" id="upload-image-btn">${t?.imageUrl ? 'Replace Image' : 'Upload Image'}</button>
<button class="btn btn--outline" id="gen-image-btn">${t?.imageUrl ? 'Replace with AI' : 'Generate with AI'}</button>
<div class="image-preview" id="thread-image-preview" ${t?.imageUrl ? '' : 'hidden'}>
<img id="thread-image-thumb" alt="Preview" ${t?.imageUrl ? `src="${this.esc(t.imageUrl)}"` : ''}>
</div>
</div>
</div>
<div class="preview" id="thread-preview">
<div class="preview-empty">Your tweet thread preview will appear here</div>
</div>
<input type="file" id="tweet-image-input" accept="image/png,image/jpeg,image/webp,image/gif" hidden>
</div>
`;
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 = '<div class="preview-empty">Your tweet thread preview will appear here</div>';
return;
}
preview.innerHTML = tweets.map((text, i) => {
const len = text.length;
const overClass = len > 280 ? ' tc-chars--over' : '';
const connector = i > 0 ? '<div class="connector"></div>' : '';
const imgUrl = this._tweetImages[String(i)];
const imgHtml = imgUrl
? `<div class="attached-image">
<img src="${this.esc(imgUrl)}" alt="Tweet image">
<button class="image-remove" data-remove-idx="${i}" title="Remove image">&#215;</button>
</div>`
: '';
const photoBtn = !imgUrl
? `<button class="photo-btn" data-photo-idx="${i}" title="Add image">${this.svgCamera}<span class="photo-plus">+</span></button>
<div class="photo-menu" hidden data-menu-idx="${i}">
<button data-upload-idx="${i}">${this.svgUpload} Upload</button>
<button data-generate-idx="${i}">${this.svgSparkle} Generate with AI</button>
</div>`
: '';
return `<div class="tweet-card">
${connector}
${photoBtn}
<div class="tc-header">
<div class="tc-avatar">${this.esc(initial)}</div>
<span class="tc-name">${this.esc(name)}</span>
<span class="tc-handle">${this.esc(handle)}</span>
<span class="tc-dot">&#183;</span>
<span class="tc-time">now</span>
</div>
<p class="tc-content">${this.esc(text)}</p>
${imgHtml}
<div class="tc-footer">
<div class="tc-actions">
<span class="tc-action">${this.svgReply}</span>
<span class="tc-action">${this.svgRetweet}</span>
<span class="tc-action">${this.svgHeart}</span>
<span class="tc-action">${this.svgShare}</span>
</div>
<div class="tc-meta">
<span class="tc-chars${overClass}">${len}/280</span>
<span class="tc-thread-num">${i + 1}/${total}</span>
</div>
</div>
</div>`;
}).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 = '<div class="drafts-empty">No saved drafts</div>';
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 `<div class="draft-item${active}">
<div class="draft-item__info" data-load-id="${this.esc(t.id)}">
<strong>${this.esc(t.title || 'Untitled')}</strong>
<span>${t.tweets.length} tweets &#183; ${dateStr}</span>
</div>
<button class="draft-item__delete" data-delete-id="${this.esc(t.id)}">&#215;</button>
</div>`;
}).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 &#9662;' : 'Saved Drafts &#9652;';
});
// 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 = `<div class="share-link">
<code>${this.esc(url)}</code>
<button id="copy-share-link">Copy</button>
</div>`;
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);