feat: remove title/header image, add Twitter-style link previews
Remove the title input and header image upload/generate section from the thread builder editor. Title is now auto-derived from first tweet. Add link preview cards that render inline in tweet content, similar to Twitter's URL card unfurling. Server-side /api/link-preview endpoint fetches OG metadata (title, description, image) with caching. URLs in tweet text are rendered as clickable links. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ffbc1ce127
commit
b591267b81
|
|
@ -32,6 +32,7 @@ export class FolkThreadBuilder extends HTMLElement {
|
||||||
private _offlineUnsub: (() => void) | null = null;
|
private _offlineUnsub: (() => void) | null = null;
|
||||||
private _offlineReady: Promise<void> | null = null;
|
private _offlineReady: Promise<void> | null = null;
|
||||||
private _tweetImageUploadIdx: string | null = null;
|
private _tweetImageUploadIdx: string | null = null;
|
||||||
|
private _linkPreviewCache: Map<string, { title: string; description: string; image: string | null; domain: string } | null> = new Map();
|
||||||
|
|
||||||
// SVG icons
|
// 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 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>';
|
||||||
|
|
@ -217,6 +218,7 @@ export class FolkThreadBuilder extends HTMLElement {
|
||||||
const tweetImgHtml = tweetImgUrl
|
const tweetImgHtml = tweetImgUrl
|
||||||
? `<div class="attached-image"><img src="${this.esc(tweetImgUrl)}" alt="Tweet image"></div>`
|
? `<div class="attached-image"><img src="${this.esc(tweetImgUrl)}" alt="Tweet image"></div>`
|
||||||
: '';
|
: '';
|
||||||
|
const linkCards = this.extractLinkCards(text);
|
||||||
return `<div class="tweet-card">
|
return `<div class="tweet-card">
|
||||||
${connector}
|
${connector}
|
||||||
<div class="tc-header">
|
<div class="tc-header">
|
||||||
|
|
@ -226,8 +228,9 @@ export class FolkThreadBuilder extends HTMLElement {
|
||||||
<span class="tc-dot">·</span>
|
<span class="tc-dot">·</span>
|
||||||
<span class="tc-time">${this.esc(dateStr)}</span>
|
<span class="tc-time">${this.esc(dateStr)}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="tc-content">${this.esc(text)}</p>
|
<p class="tc-content">${this.renderTweetContent(text)}</p>
|
||||||
${tweetImgHtml}
|
${tweetImgHtml}
|
||||||
|
${linkCards}
|
||||||
<div class="tc-footer">
|
<div class="tc-footer">
|
||||||
<div class="tc-actions">
|
<div class="tc-actions">
|
||||||
<span class="tc-action">${this.svgReply}</span>
|
<span class="tc-action">${this.svgReply}</span>
|
||||||
|
|
@ -243,10 +246,6 @@ export class FolkThreadBuilder extends HTMLElement {
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('\n');
|
}).join('\n');
|
||||||
|
|
||||||
const imageHTML = t.imageUrl
|
|
||||||
? `<div class="ro-image"><img src="${this.esc(t.imageUrl)}" alt="Thread preview"></div>`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
this.shadowRoot.innerHTML = `
|
this.shadowRoot.innerHTML = `
|
||||||
<style>${this.getBaseStyles()}${this.getReadonlyStyles()}</style>
|
<style>${this.getBaseStyles()}${this.getReadonlyStyles()}</style>
|
||||||
<div class="thread-ro">
|
<div class="thread-ro">
|
||||||
|
|
@ -264,8 +263,6 @@ export class FolkThreadBuilder extends HTMLElement {
|
||||||
<span>${this.esc(dateStr)}</span>
|
<span>${this.esc(dateStr)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${t.title ? `<h1 class="ro-title">${this.esc(t.title)}</h1>` : ''}
|
|
||||||
${imageHTML}
|
|
||||||
<div class="preview ro-cards">${tweetCards}</div>
|
<div class="preview ro-cards">${tweetCards}</div>
|
||||||
<div class="ro-actions">
|
<div class="ro-actions">
|
||||||
<a href="/${this.esc(this._space)}/rsocials/thread/${this.esc(t.id)}/edit" class="btn btn--primary">Edit Thread</a>
|
<a href="/${this.esc(this._space)}/rsocials/thread/${this.esc(t.id)}/edit" class="btn btn--primary">Edit Thread</a>
|
||||||
|
|
@ -324,17 +321,6 @@ export class FolkThreadBuilder extends HTMLElement {
|
||||||
</div>
|
</div>
|
||||||
<div id="share-link-area"></div>
|
<div id="share-link-area"></div>
|
||||||
<div class="compose">
|
<div class="compose">
|
||||||
<div class="image-section">
|
|
||||||
<input type="file" id="upload-image-input" accept="image/png,image/jpeg,image/webp,image/gif" hidden>
|
|
||||||
<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 class="image-buttons">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
<input class="compose-title" id="thread-title" placeholder="Thread title (defaults to first tweet)" value="${this.esc(t?.title || '')}">
|
|
||||||
<div class="compose-fields">
|
<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-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')}">
|
<input class="compose-input" id="thread-handle" placeholder="@handle" value="${this.esc(t?.handle || '@yourhandle')}">
|
||||||
|
|
@ -397,6 +383,7 @@ export class FolkThreadBuilder extends HTMLElement {
|
||||||
<button data-generate-idx="${i}">${this.svgSparkle} Generate with AI</button>
|
<button data-generate-idx="${i}">${this.svgSparkle} Generate with AI</button>
|
||||||
</div>`
|
</div>`
|
||||||
: '';
|
: '';
|
||||||
|
const linkCards = this.extractLinkCards(text);
|
||||||
return `<div class="tweet-card">
|
return `<div class="tweet-card">
|
||||||
${connector}
|
${connector}
|
||||||
${photoBtn}
|
${photoBtn}
|
||||||
|
|
@ -407,8 +394,9 @@ export class FolkThreadBuilder extends HTMLElement {
|
||||||
<span class="tc-dot">·</span>
|
<span class="tc-dot">·</span>
|
||||||
<span class="tc-time">now</span>
|
<span class="tc-time">now</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="tc-content">${this.esc(text)}</p>
|
<p class="tc-content">${this.renderTweetContent(text)}</p>
|
||||||
${imgHtml}
|
${imgHtml}
|
||||||
|
${linkCards}
|
||||||
<div class="tc-footer">
|
<div class="tc-footer">
|
||||||
<div class="tc-actions">
|
<div class="tc-actions">
|
||||||
<span class="tc-action">${this.svgReply}</span>
|
<span class="tc-action">${this.svgReply}</span>
|
||||||
|
|
@ -425,6 +413,94 @@ export class FolkThreadBuilder extends HTMLElement {
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Link preview helpers ──
|
||||||
|
|
||||||
|
private static URL_REGEX = /https?:\/\/[^\s<>"')\]]+/gi;
|
||||||
|
|
||||||
|
private extractUrls(text: string): string[] {
|
||||||
|
return [...text.matchAll(FolkThreadBuilder.URL_REGEX)].map(m => m[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderTweetContent(text: string): string {
|
||||||
|
// Escape first, then linkify URLs
|
||||||
|
let html = this.esc(text);
|
||||||
|
html = html.replace(/https?:\/\/[^\s<"'\]\)]+/gi, (url) => {
|
||||||
|
// Decode the escaped URL back for href
|
||||||
|
const decoded = url.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
return `<a href="${url}" class="tc-link" target="_blank" rel="noopener">${url}</a>`;
|
||||||
|
});
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractLinkCards(text: string): string {
|
||||||
|
const urls = this.extractUrls(text);
|
||||||
|
if (!urls.length) return '';
|
||||||
|
return urls.map(url => {
|
||||||
|
const cached = this._linkPreviewCache.get(url);
|
||||||
|
if (cached === undefined) {
|
||||||
|
// Not fetched yet — start fetching and show placeholder
|
||||||
|
this.fetchLinkPreview(url);
|
||||||
|
const domain = this.getDomain(url);
|
||||||
|
return `<a href="${this.esc(url)}" class="link-card link-card--loading" target="_blank" rel="noopener">
|
||||||
|
<div class="link-card__body">
|
||||||
|
<div class="link-card__domain">${this.esc(domain)}</div>
|
||||||
|
<div class="link-card__title">Loading preview...</div>
|
||||||
|
</div>
|
||||||
|
</a>`;
|
||||||
|
}
|
||||||
|
if (cached === null) {
|
||||||
|
// Fetch failed — show simple link card
|
||||||
|
const domain = this.getDomain(url);
|
||||||
|
return `<a href="${this.esc(url)}" class="link-card" target="_blank" rel="noopener">
|
||||||
|
<div class="link-card__body">
|
||||||
|
<div class="link-card__domain">${this.esc(domain)}</div>
|
||||||
|
<div class="link-card__title">${this.esc(url)}</div>
|
||||||
|
</div>
|
||||||
|
</a>`;
|
||||||
|
}
|
||||||
|
// Full preview card
|
||||||
|
const imgHtml = cached.image
|
||||||
|
? `<div class="link-card__image"><img src="${this.esc(cached.image)}" alt="" loading="lazy"></div>`
|
||||||
|
: '';
|
||||||
|
return `<a href="${this.esc(url)}" class="link-card" target="_blank" rel="noopener">
|
||||||
|
${imgHtml}
|
||||||
|
<div class="link-card__body">
|
||||||
|
<div class="link-card__domain">${this.esc(cached.domain)}</div>
|
||||||
|
<div class="link-card__title">${this.esc(cached.title)}</div>
|
||||||
|
${cached.description ? `<div class="link-card__desc">${this.esc(cached.description.substring(0, 120))}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
</a>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDomain(url: string): string {
|
||||||
|
try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return url; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchLinkPreview(url: string) {
|
||||||
|
if (this._linkPreviewCache.has(url)) return;
|
||||||
|
this._linkPreviewCache.set(url, undefined as any); // Mark as in-progress
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/link-preview?url=' + encodeURIComponent(url));
|
||||||
|
if (!res.ok) throw new Error('fetch failed');
|
||||||
|
const data = await res.json();
|
||||||
|
this._linkPreviewCache.set(url, {
|
||||||
|
title: data.title || url,
|
||||||
|
description: data.description || '',
|
||||||
|
image: data.image || null,
|
||||||
|
domain: data.domain || this.getDomain(url),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
this._linkPreviewCache.set(url, null);
|
||||||
|
}
|
||||||
|
// Re-render to show fetched preview
|
||||||
|
if (this._mode === 'readonly') {
|
||||||
|
this.renderReadonly();
|
||||||
|
} else {
|
||||||
|
this.renderPreview();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Draft management (Automerge) ──
|
// ── Draft management (Automerge) ──
|
||||||
|
|
||||||
private async saveDraft() {
|
private async saveDraft() {
|
||||||
|
|
@ -433,7 +509,6 @@ export class FolkThreadBuilder extends HTMLElement {
|
||||||
const textarea = sr.getElementById('thread-input') as HTMLTextAreaElement;
|
const textarea = sr.getElementById('thread-input') as HTMLTextAreaElement;
|
||||||
const nameInput = sr.getElementById('thread-name') as HTMLInputElement;
|
const nameInput = sr.getElementById('thread-name') as HTMLInputElement;
|
||||||
const handleInput = sr.getElementById('thread-handle') 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 saveBtn = sr.getElementById('thread-save') as HTMLButtonElement;
|
||||||
|
|
||||||
const tweets = textarea.value.split(/\n---\n/).map(t => t.trim()).filter(Boolean);
|
const tweets = textarea.value.split(/\n---\n/).map(t => t.trim()).filter(Boolean);
|
||||||
|
|
@ -447,7 +522,7 @@ export class FolkThreadBuilder extends HTMLElement {
|
||||||
id: this._threadId,
|
id: this._threadId,
|
||||||
name: nameInput.value || 'Your Name',
|
name: nameInput.value || 'Your Name',
|
||||||
handle: handleInput.value || '@yourhandle',
|
handle: handleInput.value || '@yourhandle',
|
||||||
title: titleInput.value || tweets[0].substring(0, 60),
|
title: tweets[0].substring(0, 60),
|
||||||
tweets,
|
tweets,
|
||||||
tweetImages: Object.keys(this._tweetImages).length ? { ...this._tweetImages } : null,
|
tweetImages: Object.keys(this._tweetImages).length ? { ...this._tweetImages } : null,
|
||||||
imageUrl: this._thread?.imageUrl || null,
|
imageUrl: this._thread?.imageUrl || null,
|
||||||
|
|
@ -485,27 +560,10 @@ export class FolkThreadBuilder extends HTMLElement {
|
||||||
const textarea = sr.getElementById('thread-input') as HTMLTextAreaElement;
|
const textarea = sr.getElementById('thread-input') as HTMLTextAreaElement;
|
||||||
const nameInput = sr.getElementById('thread-name') as HTMLInputElement;
|
const nameInput = sr.getElementById('thread-name') as HTMLInputElement;
|
||||||
const handleInput = sr.getElementById('thread-handle') 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');
|
textarea.value = thread.tweets.join('\n---\n');
|
||||||
nameInput.value = thread.name || '';
|
nameInput.value = thread.name || '';
|
||||||
handleInput.value = thread.handle || '';
|
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');
|
history.replaceState(null, '', this.basePath + 'thread/' + thread.id + '/edit');
|
||||||
this.renderPreview();
|
this.renderPreview();
|
||||||
|
|
@ -531,13 +589,7 @@ export class FolkThreadBuilder extends HTMLElement {
|
||||||
|
|
||||||
const sr = this.shadowRoot;
|
const sr = this.shadowRoot;
|
||||||
if (sr) {
|
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;
|
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 = '';
|
if (shareLinkArea) shareLinkArea.innerHTML = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -582,91 +634,6 @@ export class FolkThreadBuilder extends HTMLElement {
|
||||||
|
|
||||||
// ── Image operations (server API) ──
|
// ── Image operations (server API) ──
|
||||||
|
|
||||||
private async generateImage() {
|
|
||||||
if (!this._threadId) {
|
|
||||||
await 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;
|
|
||||||
await 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) {
|
|
||||||
await 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;
|
|
||||||
await 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) {
|
private async uploadTweetImage(index: string, file: File) {
|
||||||
if (!this._threadId) {
|
if (!this._threadId) {
|
||||||
await this.saveDraft();
|
await this.saveDraft();
|
||||||
|
|
@ -782,13 +749,9 @@ export class FolkThreadBuilder extends HTMLElement {
|
||||||
const textarea = sr.getElementById('thread-input') as HTMLTextAreaElement;
|
const textarea = sr.getElementById('thread-input') as HTMLTextAreaElement;
|
||||||
const nameInput = sr.getElementById('thread-name') as HTMLInputElement;
|
const nameInput = sr.getElementById('thread-name') as HTMLInputElement;
|
||||||
const handleInput = sr.getElementById('thread-handle') 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 saveBtn = sr.getElementById('thread-save');
|
||||||
const shareBtn = sr.getElementById('thread-share');
|
const shareBtn = sr.getElementById('thread-share');
|
||||||
const copyBtn = sr.getElementById('thread-copy');
|
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 toggleDraftsBtn = sr.getElementById('toggle-drafts');
|
||||||
const draftsList = sr.getElementById('drafts-list') as HTMLElement;
|
const draftsList = sr.getElementById('drafts-list') as HTMLElement;
|
||||||
const preview = sr.getElementById('thread-preview') as HTMLElement;
|
const preview = sr.getElementById('thread-preview') as HTMLElement;
|
||||||
|
|
@ -803,18 +766,10 @@ export class FolkThreadBuilder extends HTMLElement {
|
||||||
textarea?.addEventListener('blur', () => this.scheduleAutoSave());
|
textarea?.addEventListener('blur', () => this.scheduleAutoSave());
|
||||||
nameInput?.addEventListener('blur', () => this.scheduleAutoSave());
|
nameInput?.addEventListener('blur', () => this.scheduleAutoSave());
|
||||||
handleInput?.addEventListener('blur', () => this.scheduleAutoSave());
|
handleInput?.addEventListener('blur', () => this.scheduleAutoSave());
|
||||||
titleInput?.addEventListener('blur', () => this.scheduleAutoSave());
|
|
||||||
|
|
||||||
// Buttons
|
// Buttons
|
||||||
saveBtn?.addEventListener('click', () => this.saveDraft());
|
saveBtn?.addEventListener('click', () => this.saveDraft());
|
||||||
shareBtn?.addEventListener('click', () => this.shareThread());
|
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
|
// Drafts toggle
|
||||||
toggleDraftsBtn?.addEventListener('click', () => {
|
toggleDraftsBtn?.addEventListener('click', () => {
|
||||||
|
|
@ -935,7 +890,6 @@ export class FolkThreadBuilder extends HTMLElement {
|
||||||
if (!sr) return;
|
if (!sr) return;
|
||||||
const shareBtn = sr.getElementById('thread-share') as HTMLButtonElement;
|
const shareBtn = sr.getElementById('thread-share') as HTMLButtonElement;
|
||||||
const shareLinkArea = sr.getElementById('share-link-area') as HTMLElement;
|
const shareLinkArea = sr.getElementById('share-link-area') as HTMLElement;
|
||||||
const imagePreview = sr.getElementById('thread-image-preview') as HTMLElement;
|
|
||||||
|
|
||||||
shareBtn.textContent = 'Saving...';
|
shareBtn.textContent = 'Saving...';
|
||||||
shareBtn.disabled = true;
|
shareBtn.disabled = true;
|
||||||
|
|
@ -944,11 +898,6 @@ export class FolkThreadBuilder extends HTMLElement {
|
||||||
await this.saveDraft();
|
await this.saveDraft();
|
||||||
if (!this._threadId) { shareBtn.textContent = 'Share'; shareBtn.disabled = false; return; }
|
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;
|
const url = window.location.origin + this.basePath + 'thread/' + this._threadId;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(url);
|
await navigator.clipboard.writeText(url);
|
||||||
|
|
@ -1135,12 +1084,6 @@ export class FolkThreadBuilder extends HTMLElement {
|
||||||
}
|
}
|
||||||
.draft-item__delete:hover { color: #ef4444; }
|
.draft-item__delete:hover { color: #ef4444; }
|
||||||
|
|
||||||
.image-section { display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 0.75rem; }
|
|
||||||
.image-buttons { display: flex; gap: 0.5rem; }
|
|
||||||
.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; width: 100%; max-height: 200px; object-fit: cover; }
|
|
||||||
|
|
||||||
#share-link-area { grid-column: 1 / -1; }
|
#share-link-area { grid-column: 1 / -1; }
|
||||||
.share-link {
|
.share-link {
|
||||||
display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.75rem;
|
display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.75rem;
|
||||||
|
|
@ -1182,6 +1125,22 @@ export class FolkThreadBuilder extends HTMLElement {
|
||||||
.photo-menu button + button { border-top: 1px solid var(--rs-bg-hover, #334155); }
|
.photo-menu button + button { border-top: 1px solid var(--rs-bg-hover, #334155); }
|
||||||
.photo-menu button svg { width: 14px; height: 14px; }
|
.photo-menu button svg { width: 14px; height: 14px; }
|
||||||
|
|
||||||
|
.link-card {
|
||||||
|
display: flex; overflow: hidden; border: 1px solid var(--rs-input-border, #334155);
|
||||||
|
border-radius: 12px; text-decoration: none; color: inherit; margin-top: 0.5rem;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.link-card:hover { border-color: #6366f1; }
|
||||||
|
.link-card--loading { opacity: 0.6; }
|
||||||
|
.link-card__image { width: 120px; min-height: 80px; flex-shrink: 0; overflow: hidden; background: var(--rs-bg-hover, #334155); }
|
||||||
|
.link-card__image img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.link-card__body { padding: 0.5rem 0.75rem; min-width: 0; flex: 1; display: flex; flex-direction: column; justify-content: center; gap: 0.15rem; }
|
||||||
|
.link-card__domain { font-size: 0.7rem; color: var(--rs-text-muted, #64748b); text-transform: lowercase; }
|
||||||
|
.link-card__title { font-size: 0.82rem; font-weight: 600; color: var(--rs-text-primary, #f1f5f9); line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
|
.link-card__desc { font-size: 0.75rem; color: var(--rs-text-secondary, #94a3b8); line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
|
.tc-link { color: #7dd3fc; text-decoration: none; }
|
||||||
|
.tc-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.thread-page { grid-template-columns: 1fr; }
|
.thread-page { grid-template-columns: 1fr; }
|
||||||
.compose { position: static; }
|
.compose { position: static; }
|
||||||
|
|
@ -1197,9 +1156,6 @@ export class FolkThreadBuilder extends HTMLElement {
|
||||||
.ro-name { font-weight: 700; color: var(--rs-text-primary, #f1f5f9); font-size: 1.1rem; }
|
.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-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-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-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-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; }
|
.ro-cta { display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap; }
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,72 @@ app.get("/data/files/generated/:filename", async (c) => {
|
||||||
return new Response(file, { headers: { "Content-Type": mimeMap[ext] || "application/octet-stream", "Cache-Control": "public, max-age=86400" } });
|
return new Response(file, { headers: { "Content-Type": mimeMap[ext] || "application/octet-stream", "Cache-Control": "public, max-age=86400" } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Link preview / unfurl API ──
|
||||||
|
const linkPreviewCache = new Map<string, { title: string; description: string; image: string | null; domain: string; fetchedAt: number }>();
|
||||||
|
|
||||||
|
app.get("/api/link-preview", async (c) => {
|
||||||
|
const url = c.req.query("url");
|
||||||
|
if (!url) return c.json({ error: "Missing url parameter" }, 400);
|
||||||
|
|
||||||
|
try { new URL(url); } catch { return c.json({ error: "Invalid URL" }, 400); }
|
||||||
|
|
||||||
|
// Check cache (1 hour TTL)
|
||||||
|
const cached = linkPreviewCache.get(url);
|
||||||
|
if (cached && Date.now() - cached.fetchedAt < 3600_000) {
|
||||||
|
return c.json(cached, 200, { "Cache-Control": "public, max-age=3600" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { "User-Agent": "Mozilla/5.0 (compatible; rSpace/1.0; +https://rspace.online)", "Accept": "text/html" },
|
||||||
|
signal: controller.signal,
|
||||||
|
redirect: "follow",
|
||||||
|
});
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
if (!res.ok) return c.json({ error: "Fetch failed" }, 502);
|
||||||
|
const contentType = res.headers.get("content-type") || "";
|
||||||
|
if (!contentType.includes("text/html")) {
|
||||||
|
const domain = new URL(url).hostname.replace(/^www\./, "");
|
||||||
|
const result = { title: url, description: "", image: null, domain, fetchedAt: Date.now() };
|
||||||
|
linkPreviewCache.set(url, result);
|
||||||
|
return c.json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = await res.text();
|
||||||
|
const getMetaContent = (nameOrProp: string): string => {
|
||||||
|
const re = new RegExp(`<meta[^>]*(?:name|property)=["']${nameOrProp}["'][^>]*content=["']([^"']*?)["']`, "i");
|
||||||
|
const re2 = new RegExp(`<meta[^>]*content=["']([^"']*?)["'][^>]*(?:name|property)=["']${nameOrProp}["']`, "i");
|
||||||
|
return re.exec(html)?.[1] || re2.exec(html)?.[1] || "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const ogTitle = getMetaContent("og:title") || getMetaContent("twitter:title");
|
||||||
|
const titleMatch = html.match(/<title[^>]*>([^<]*)<\/title>/i);
|
||||||
|
const title = ogTitle || titleMatch?.[1]?.trim() || url;
|
||||||
|
const description = getMetaContent("og:description") || getMetaContent("twitter:description") || getMetaContent("description");
|
||||||
|
let image = getMetaContent("og:image") || getMetaContent("twitter:image") || null;
|
||||||
|
if (image && !image.startsWith("http")) {
|
||||||
|
try { image = new URL(image, url).href; } catch { image = null; }
|
||||||
|
}
|
||||||
|
const domain = new URL(url).hostname.replace(/^www\./, "");
|
||||||
|
|
||||||
|
const result = { title, description, image, domain, fetchedAt: Date.now() };
|
||||||
|
linkPreviewCache.set(url, result);
|
||||||
|
|
||||||
|
// Cap cache size
|
||||||
|
if (linkPreviewCache.size > 500) {
|
||||||
|
const oldest = [...linkPreviewCache.entries()].sort((a, b) => a[1].fetchedAt - b[1].fetchedAt);
|
||||||
|
for (let i = 0; i < 100; i++) linkPreviewCache.delete(oldest[i][0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(result, 200, { "Cache-Control": "public, max-age=3600" });
|
||||||
|
} catch {
|
||||||
|
return c.json({ error: "Failed to fetch URL" }, 502);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── Space registry API ──
|
// ── Space registry API ──
|
||||||
app.route("/api/spaces", spaces);
|
app.route("/api/spaces", spaces);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue