1173 lines
48 KiB
TypeScript
1173 lines
48 KiB
TypeScript
/**
|
||
* <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, '&').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 ? '<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">·</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>·</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 ▾</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 ▾</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 ▾</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">×</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">·</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 · ${dateStr}</span>
|
||
</div>
|
||
<button class="draft-item__delete" data-delete-id="${this.esc(t.id)}">×</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 ▾' : '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 = `<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);
|