feat: add photo upload for thread preview images
Allow users to upload their own image as a thread preview alongside the existing AI generation option. Adds upload-image API endpoint with file type/size validation and two side-by-side UI buttons. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
cdfe8c5b78
commit
20b75c999d
|
|
@ -285,13 +285,66 @@ routes.post("/api/threads/:id/image", async (c) => {
|
|||
return c.json({ imageUrl });
|
||||
});
|
||||
|
||||
routes.post("/api/threads/:id/upload-image", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400);
|
||||
|
||||
const thread = await loadThread(id);
|
||||
if (!thread) return c.json({ error: "Thread not found" }, 404);
|
||||
|
||||
let formData: FormData;
|
||||
try {
|
||||
formData = await c.req.formData();
|
||||
} catch {
|
||||
return c.json({ error: "Invalid form data" }, 400);
|
||||
}
|
||||
|
||||
const file = formData.get("file");
|
||||
if (!file || !(file instanceof File)) return c.json({ error: "No file provided" }, 400);
|
||||
|
||||
const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
return c.json({ error: "Invalid file type. Allowed: png, jpg, webp, gif" }, 400);
|
||||
}
|
||||
|
||||
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
if (file.size > MAX_SIZE) {
|
||||
return c.json({ error: "File too large. Maximum 5MB" }, 400);
|
||||
}
|
||||
|
||||
const ext = file.name.split(".").pop()?.toLowerCase() || "png";
|
||||
const safeExt = ["png", "jpg", "jpeg", "webp", "gif"].includes(ext) ? ext : "png";
|
||||
const filename = `thread-${id}.${safeExt}`;
|
||||
|
||||
const genDir = resolve(process.env.FILES_DIR || "./data/files", "generated");
|
||||
await mkdir(genDir, { recursive: true });
|
||||
|
||||
// Delete old image if it exists with a different extension
|
||||
if (thread.imageUrl) {
|
||||
const oldFilename = thread.imageUrl.split("/").pop();
|
||||
if (oldFilename && oldFilename !== filename) {
|
||||
try { await unlink(resolve(genDir, oldFilename)); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await writeFile(resolve(genDir, filename), buffer);
|
||||
|
||||
const imageUrl = `/data/files/generated/${filename}`;
|
||||
thread.imageUrl = imageUrl;
|
||||
thread.updatedAt = Date.now();
|
||||
await saveThread(thread);
|
||||
|
||||
return c.json({ imageUrl });
|
||||
});
|
||||
|
||||
// ── Demo feed data (server-rendered, no API calls) ──
|
||||
const DEMO_FEED = [
|
||||
{
|
||||
username: "@alice",
|
||||
initial: "A",
|
||||
color: "#6366f1",
|
||||
content: "Just deployed the new rFunds river view! The enoughness score is such a powerful concept. \u{1F30A}",
|
||||
content: "Just deployed the new rFlows river view! The enoughness score is such a powerful concept. \u{1F30A}",
|
||||
timeAgo: "2 hours ago",
|
||||
likes: 5,
|
||||
replies: 2,
|
||||
|
|
@ -694,6 +747,21 @@ const THREAD_CSS = `
|
|||
.thread-page { grid-template-columns: 1fr; }
|
||||
.thread-compose { position: static; }
|
||||
}
|
||||
.thread-export-dropdown { position: relative; }
|
||||
.thread-export-menu {
|
||||
position: absolute; top: calc(100% + 4px); right: 0; z-index: 100;
|
||||
background: #1e293b; border: 1px solid #334155; border-radius: 8px;
|
||||
min-width: 180px; overflow: hidden;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||||
}
|
||||
.thread-export-menu[hidden] { display: none; }
|
||||
.thread-export-menu button {
|
||||
display: block; width: 100%; padding: 0.6rem 0.75rem; border: none;
|
||||
background: transparent; color: #e2e8f0; font-size: 0.85rem;
|
||||
text-align: left; cursor: pointer; transition: background 0.1s;
|
||||
}
|
||||
.thread-export-menu button:hover { background: rgba(99,102,241,0.15); }
|
||||
.thread-export-menu button + button { border-top: 1px solid rgba(255,255,255,0.05); }
|
||||
`;
|
||||
|
||||
function renderThreadBuilderPage(space: string, threadData?: ThreadData | null): string {
|
||||
|
|
@ -712,6 +780,16 @@ function renderThreadBuilderPage(space: string, threadData?: ThreadData | null):
|
|||
<button class="thread-btn thread-btn--primary" id="thread-save">Save Draft</button>
|
||||
<button class="thread-btn thread-btn--success" id="thread-share">Share</button>
|
||||
<button class="thread-btn thread-btn--outline" id="thread-copy">Copy Thread</button>
|
||||
<div class="thread-export-dropdown">
|
||||
<button class="thread-btn thread-btn--outline" id="thread-export-btn">Export ▾</button>
|
||||
<div class="thread-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="thread-drafts">
|
||||
|
|
@ -727,7 +805,9 @@ function renderThreadBuilderPage(space: string, threadData?: ThreadData | null):
|
|||
</div>
|
||||
<input class="thread-compose__title" id="thread-title" placeholder="Thread title (defaults to first tweet)">
|
||||
<div class="thread-image-section">
|
||||
<button class="thread-btn thread-btn--outline" id="gen-image-btn">Generate Preview Image</button>
|
||||
<input type="file" id="upload-image-input" accept="image/png,image/jpeg,image/webp,image/gif" hidden>
|
||||
<button class="thread-btn thread-btn--outline" id="upload-image-btn">Upload Image</button>
|
||||
<button class="thread-btn thread-btn--outline" id="gen-image-btn">Generate with AI</button>
|
||||
<div class="thread-image-preview" id="thread-image-preview" hidden>
|
||||
<img id="thread-image-thumb" alt="Preview">
|
||||
</div>
|
||||
|
|
@ -751,6 +831,8 @@ function renderThreadBuilderPage(space: string, threadData?: ThreadData | null):
|
|||
const saveBtn = document.getElementById('thread-save');
|
||||
const shareBtn = document.getElementById('thread-share');
|
||||
const genImageBtn = document.getElementById('gen-image-btn');
|
||||
const uploadImageBtn = document.getElementById('upload-image-btn');
|
||||
const uploadImageInput = document.getElementById('upload-image-input');
|
||||
const toggleDraftsBtn = document.getElementById('toggle-drafts');
|
||||
const draftsList = document.getElementById('drafts-list');
|
||||
const imagePreview = document.getElementById('thread-image-preview');
|
||||
|
|
@ -838,7 +920,7 @@ function renderThreadBuilderPage(space: string, threadData?: ThreadData | null):
|
|||
const data = await res.json();
|
||||
if (data.id) {
|
||||
currentThreadId = data.id;
|
||||
history.replaceState(null, '', base + 'thread/' + data.id);
|
||||
history.replaceState(null, '', base + 'thread/' + data.id + '/edit');
|
||||
}
|
||||
|
||||
saveBtn.textContent = 'Saved!';
|
||||
|
|
@ -865,13 +947,15 @@ function renderThreadBuilderPage(space: string, threadData?: ThreadData | null):
|
|||
if (data.imageUrl) {
|
||||
imageThumb.src = data.imageUrl;
|
||||
imagePreview.hidden = false;
|
||||
genImageBtn.textContent = 'Regenerate Image';
|
||||
genImageBtn.textContent = 'Replace with AI';
|
||||
uploadImageBtn.textContent = 'Replace Image';
|
||||
} else {
|
||||
imagePreview.hidden = true;
|
||||
genImageBtn.textContent = 'Generate Preview Image';
|
||||
genImageBtn.textContent = 'Generate with AI';
|
||||
uploadImageBtn.textContent = 'Upload Image';
|
||||
}
|
||||
|
||||
history.replaceState(null, '', base + 'thread/' + data.id);
|
||||
history.replaceState(null, '', base + 'thread/' + data.id + '/edit');
|
||||
renderPreview();
|
||||
loadDraftList();
|
||||
} catch (e) {
|
||||
|
|
@ -887,7 +971,8 @@ function renderThreadBuilderPage(space: string, threadData?: ThreadData | null):
|
|||
currentThreadId = null;
|
||||
history.replaceState(null, '', base + 'thread');
|
||||
imagePreview.hidden = true;
|
||||
genImageBtn.textContent = 'Generate Preview Image';
|
||||
genImageBtn.textContent = 'Generate with AI';
|
||||
uploadImageBtn.textContent = 'Upload Image';
|
||||
shareLinkArea.innerHTML = '';
|
||||
}
|
||||
loadDraftList();
|
||||
|
|
@ -981,14 +1066,15 @@ function renderThreadBuilderPage(space: string, threadData?: ThreadData | null):
|
|||
if (data.imageUrl) {
|
||||
imageThumb.src = data.imageUrl;
|
||||
imagePreview.hidden = false;
|
||||
genImageBtn.textContent = 'Regenerate Image';
|
||||
genImageBtn.textContent = 'Replace with AI';
|
||||
uploadImageBtn.textContent = 'Replace Image';
|
||||
} else {
|
||||
genImageBtn.textContent = 'Generation Failed';
|
||||
setTimeout(() => { genImageBtn.textContent = 'Generate Preview Image'; }, 2000);
|
||||
setTimeout(() => { genImageBtn.textContent = imagePreview.hidden ? 'Generate with AI' : 'Replace with AI'; }, 2000);
|
||||
}
|
||||
} catch (e) {
|
||||
genImageBtn.textContent = 'Generation Failed';
|
||||
setTimeout(() => { genImageBtn.textContent = 'Generate Preview Image'; }, 2000);
|
||||
setTimeout(() => { genImageBtn.textContent = imagePreview.hidden ? 'Generate with AI' : 'Replace with AI'; }, 2000);
|
||||
} finally {
|
||||
genImageBtn.disabled = false;
|
||||
}
|
||||
|
|
@ -1011,9 +1097,47 @@ function renderThreadBuilderPage(space: string, threadData?: ThreadData | null):
|
|||
handleInput.addEventListener('blur', scheduleAutoSave);
|
||||
titleInput.addEventListener('blur', scheduleAutoSave);
|
||||
|
||||
async function uploadImage(file) {
|
||||
if (!currentThreadId) {
|
||||
await saveDraft();
|
||||
if (!currentThreadId) return;
|
||||
}
|
||||
|
||||
uploadImageBtn.textContent = 'Uploading...';
|
||||
uploadImageBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const res = await fetch(base + 'api/threads/' + currentThreadId + '/upload-image', { method: 'POST', body: form });
|
||||
const data = await res.json();
|
||||
|
||||
if (data.imageUrl) {
|
||||
imageThumb.src = data.imageUrl;
|
||||
imagePreview.hidden = false;
|
||||
uploadImageBtn.textContent = 'Replace Image';
|
||||
genImageBtn.textContent = 'Replace with AI';
|
||||
} else {
|
||||
uploadImageBtn.textContent = data.error || 'Upload Failed';
|
||||
setTimeout(() => { uploadImageBtn.textContent = imagePreview.hidden ? 'Upload Image' : 'Replace Image'; }, 2000);
|
||||
}
|
||||
} catch (e) {
|
||||
uploadImageBtn.textContent = 'Upload Failed';
|
||||
setTimeout(() => { uploadImageBtn.textContent = imagePreview.hidden ? 'Upload Image' : 'Replace Image'; }, 2000);
|
||||
} finally {
|
||||
uploadImageBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
saveBtn.addEventListener('click', saveDraft);
|
||||
shareBtn.addEventListener('click', shareThread);
|
||||
genImageBtn.addEventListener('click', generateImage);
|
||||
uploadImageBtn.addEventListener('click', () => uploadImageInput.click());
|
||||
uploadImageInput.addEventListener('change', () => {
|
||||
const file = uploadImageInput.files?.[0];
|
||||
if (file) uploadImage(file);
|
||||
uploadImageInput.value = '';
|
||||
});
|
||||
|
||||
toggleDraftsBtn.addEventListener('click', () => {
|
||||
draftsList.hidden = !draftsList.hidden;
|
||||
|
|
@ -1035,6 +1159,61 @@ function renderThreadBuilderPage(space: string, threadData?: ThreadData | null):
|
|||
}
|
||||
});
|
||||
|
||||
// ── Export dropdown ──
|
||||
const exportBtn = document.getElementById('thread-export-btn');
|
||||
const exportMenu = document.getElementById('thread-export-menu');
|
||||
if (exportBtn && exportMenu) {
|
||||
exportBtn.addEventListener('click', () => { exportMenu.hidden = !exportMenu.hidden; });
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!exportBtn.contains(e.target) && !exportMenu.contains(e.target)) exportMenu.hidden = true;
|
||||
});
|
||||
|
||||
const LIMITS = { twitter: 280, bluesky: 300, mastodon: 500, linkedin: 3000, plain: Infinity };
|
||||
|
||||
function formatForPlatform(platform) {
|
||||
const limit = LIMITS[platform] || 280;
|
||||
const tweets = textarea.value.split(/\\n---\\n/).map(t => t.trim()).filter(Boolean);
|
||||
const total = tweets.length;
|
||||
const title = titleInput.value;
|
||||
let warnings = [];
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
exportMenu.querySelectorAll('button[data-platform]').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const platform = btn.dataset.platform;
|
||||
const tweets = textarea.value.split(/\\n---\\n/).map(t => t.trim()).filter(Boolean);
|
||||
if (!tweets.length) return;
|
||||
const { text, warnings } = formatForPlatform(platform);
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
const label = warnings.length
|
||||
? 'Copied with warnings: ' + warnings.join('; ')
|
||||
: 'Copied for ' + platform + '!';
|
||||
copyBtn.textContent = label;
|
||||
setTimeout(() => { copyBtn.textContent = 'Copy Thread'; }, 3000);
|
||||
} catch { copyBtn.textContent = 'Failed'; setTimeout(() => { copyBtn.textContent = 'Copy Thread'; }, 2000); }
|
||||
exportMenu.hidden = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Load initial data ──
|
||||
if (window.__THREAD_DATA__) {
|
||||
const data = window.__THREAD_DATA__;
|
||||
|
|
@ -1046,7 +1225,8 @@ function renderThreadBuilderPage(space: string, threadData?: ThreadData | null):
|
|||
if (data.imageUrl) {
|
||||
imageThumb.src = data.imageUrl;
|
||||
imagePreview.hidden = false;
|
||||
genImageBtn.textContent = 'Regenerate Image';
|
||||
genImageBtn.textContent = 'Replace with AI';
|
||||
uploadImageBtn.textContent = 'Replace Image';
|
||||
}
|
||||
renderPreview();
|
||||
}
|
||||
|
|
@ -1054,7 +1234,207 @@ function renderThreadBuilderPage(space: string, threadData?: ThreadData | null):
|
|||
</script>`;
|
||||
}
|
||||
|
||||
// ── Thread permalink with OG tags ──
|
||||
// ── Thread read-only view (shareable permalink) ──
|
||||
function renderThreadReadOnly(space: string, thread: ThreadData): string {
|
||||
const name = escapeHtml(thread.name || "Anonymous");
|
||||
const handle = escapeHtml(thread.handle || "@anonymous");
|
||||
const initial = name.charAt(0).toUpperCase();
|
||||
const total = thread.tweets.length;
|
||||
const dateStr = new Date(thread.createdAt).toLocaleDateString("en-US", {
|
||||
month: "long", day: "numeric", year: "numeric",
|
||||
});
|
||||
|
||||
const tweetCards = thread.tweets.map((text, i) => {
|
||||
const len = text.length;
|
||||
const connector = i > 0 ? '<div class="tweet-card__connector"></div>' : "";
|
||||
return `<div class="tweet-card">
|
||||
${connector}
|
||||
<div class="tweet-card__header">
|
||||
<div class="tweet-card__avatar">${escapeHtml(initial)}</div>
|
||||
<span class="tweet-card__name">${name}</span>
|
||||
<span class="tweet-card__handle">${handle}</span>
|
||||
<span class="tweet-card__dot">·</span>
|
||||
<span class="tweet-card__time">${escapeHtml(dateStr)}</span>
|
||||
</div>
|
||||
<p class="tweet-card__content">${escapeHtml(text)}</p>
|
||||
<div class="tweet-card__footer">
|
||||
<div class="tweet-card__actions">
|
||||
<span class="tweet-card__action"><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></span>
|
||||
<span class="tweet-card__action"><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></span>
|
||||
<span class="tweet-card__action"><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></span>
|
||||
<span class="tweet-card__action"><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></span>
|
||||
</div>
|
||||
<div class="tweet-card__meta">
|
||||
<span class="tweet-card__chars${len > 280 ? " tweet-card__chars--over" : ""}">${len}/280</span>
|
||||
<span class="tweet-card__thread-num">${i + 1}/${total}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join("\n");
|
||||
|
||||
const imageHTML = thread.imageUrl
|
||||
? `<div class="thread-ro__image"><img src="${escapeHtml(thread.imageUrl)}" alt="Thread preview"></div>`
|
||||
: "";
|
||||
|
||||
return `
|
||||
<div class="thread-ro">
|
||||
<div class="thread-ro__header">
|
||||
<div class="thread-ro__author">
|
||||
<div class="tweet-card__avatar" style="width:48px;height:48px;font-size:1.2rem">${escapeHtml(initial)}</div>
|
||||
<div>
|
||||
<div class="thread-ro__name">${name}</div>
|
||||
<div class="thread-ro__handle">${handle}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="thread-ro__meta">
|
||||
<span>${total} tweet${total === 1 ? "" : "s"}</span>
|
||||
<span>·</span>
|
||||
<span>${escapeHtml(dateStr)}</span>
|
||||
</div>
|
||||
</div>
|
||||
${thread.title ? `<h1 class="thread-ro__title">${escapeHtml(thread.title)}</h1>` : ""}
|
||||
${imageHTML}
|
||||
<div class="thread-preview thread-ro__cards">
|
||||
${tweetCards}
|
||||
</div>
|
||||
<div class="thread-ro__actions">
|
||||
<a href="/${escapeHtml(space)}/rsocials/thread/${escapeHtml(thread.id)}/edit" class="thread-btn thread-btn--primary">Edit Thread</a>
|
||||
<button class="thread-btn thread-btn--outline" id="ro-copy-thread">Copy Thread</button>
|
||||
<button class="thread-btn thread-btn--outline" id="ro-copy-link">Copy Link</button>
|
||||
<div class="thread-export-dropdown">
|
||||
<button class="thread-btn thread-btn--outline" id="ro-export-btn">Export ▾</button>
|
||||
<div class="thread-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="thread-ro__cta">
|
||||
<a href="/${escapeHtml(space)}/rsocials/thread" class="thread-btn thread-btn--success">Create Your Own Thread</a>
|
||||
<a href="/${escapeHtml(space)}/rsocials/threads" class="thread-btn thread-btn--outline">Browse All Threads</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="thread-export-toast" id="export-toast" hidden></div>
|
||||
<script>
|
||||
(function() {
|
||||
const threadData = ${JSON.stringify({ tweets: thread.tweets, name: thread.name, handle: thread.handle, title: thread.title }).replace(/</g, "\\u003c")};
|
||||
const url = window.location.href;
|
||||
|
||||
function showToast(msg) {
|
||||
const toast = document.getElementById('export-toast');
|
||||
toast.textContent = msg;
|
||||
toast.hidden = false;
|
||||
setTimeout(() => { toast.hidden = true; }, 2500);
|
||||
}
|
||||
|
||||
document.getElementById('ro-copy-thread')?.addEventListener('click', async () => {
|
||||
const text = threadData.tweets.map((t, i) => (i + 1) + '/' + threadData.tweets.length + '\\n' + t).join('\\n\\n');
|
||||
try { await navigator.clipboard.writeText(text); showToast('Thread copied!'); }
|
||||
catch { showToast('Failed to copy'); }
|
||||
});
|
||||
|
||||
document.getElementById('ro-copy-link')?.addEventListener('click', async () => {
|
||||
try { await navigator.clipboard.writeText(url); showToast('Link copied!'); }
|
||||
catch { showToast('Failed to copy'); }
|
||||
});
|
||||
|
||||
// Export dropdown
|
||||
const exportBtn = document.getElementById('ro-export-btn');
|
||||
const exportMenu = document.getElementById('ro-export-menu');
|
||||
exportBtn?.addEventListener('click', () => { exportMenu.hidden = !exportMenu.hidden; });
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!exportBtn?.contains(e.target) && !exportMenu?.contains(e.target)) exportMenu.hidden = true;
|
||||
});
|
||||
|
||||
const LIMITS = { twitter: 280, bluesky: 300, mastodon: 500, linkedin: 3000, plain: Infinity };
|
||||
|
||||
function formatForPlatform(platform) {
|
||||
const limit = LIMITS[platform] || 280;
|
||||
const tweets = threadData.tweets;
|
||||
const total = tweets.length;
|
||||
const name = threadData.name || 'Thread';
|
||||
const handle = threadData.handle || '';
|
||||
let warnings = [];
|
||||
|
||||
if (platform === 'linkedin') {
|
||||
let text = '';
|
||||
if (threadData.title) text += threadData.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 };
|
||||
}
|
||||
|
||||
exportMenu?.querySelectorAll('button[data-platform]').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const platform = btn.dataset.platform;
|
||||
const { text, warnings } = formatForPlatform(platform);
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
if (warnings.length) {
|
||||
showToast('Copied with warnings: ' + warnings.join('; '));
|
||||
} else {
|
||||
showToast('Copied for ' + platform + '!');
|
||||
}
|
||||
} catch { showToast('Failed to copy'); }
|
||||
exportMenu.hidden = true;
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>`;
|
||||
}
|
||||
|
||||
const THREAD_RO_CSS = `
|
||||
.thread-ro { max-width: 640px; margin: 0 auto; padding: 2rem 1rem; }
|
||||
.thread-ro__header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem; }
|
||||
.thread-ro__author { display: flex; align-items: center; gap: 0.75rem; }
|
||||
.thread-ro__name { font-weight: 700; color: #f1f5f9; font-size: 1.1rem; }
|
||||
.thread-ro__handle { color: #64748b; font-size: 0.9rem; }
|
||||
.thread-ro__meta { display: flex; align-items: center; gap: 0.5rem; color: #64748b; font-size: 0.85rem; }
|
||||
.thread-ro__title { font-size: 1.4rem; color: #f1f5f9; margin: 0 0 1.5rem; line-height: 1.3; }
|
||||
.thread-ro__image { margin-bottom: 1.5rem; border-radius: 12px; overflow: hidden; border: 1px solid #334155; }
|
||||
.thread-ro__image img { display: block; width: 100%; height: auto; }
|
||||
.thread-ro__cards { margin-bottom: 1.5rem; }
|
||||
.thread-ro__actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 2rem; padding-bottom: 2rem; border-bottom: 1px solid #334155; }
|
||||
.thread-ro__cta { display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap; }
|
||||
.thread-export-dropdown { position: relative; }
|
||||
.thread-export-menu {
|
||||
position: absolute; top: calc(100% + 4px); right: 0; z-index: 100;
|
||||
background: #1e293b; border: 1px solid #334155; border-radius: 8px;
|
||||
min-width: 180px; overflow: hidden;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||||
}
|
||||
.thread-export-menu[hidden] { display: none; }
|
||||
.thread-export-menu button {
|
||||
display: block; width: 100%; padding: 0.6rem 0.75rem; border: none;
|
||||
background: transparent; color: #e2e8f0; font-size: 0.85rem;
|
||||
text-align: left; cursor: pointer; transition: background 0.1s;
|
||||
}
|
||||
.thread-export-menu button:hover { background: rgba(99,102,241,0.15); }
|
||||
.thread-export-menu button + button { border-top: 1px solid rgba(255,255,255,0.05); }
|
||||
.thread-export-toast {
|
||||
position: fixed; bottom: 1.5rem; left: 50%; transform: translateX(-50%);
|
||||
background: #1e293b; border: 1px solid #6366f1; color: #c4b5fd;
|
||||
padding: 0.6rem 1.25rem; border-radius: 8px; font-size: 0.85rem;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4); z-index: 1000;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.thread-export-toast[hidden] { display: none; }
|
||||
`;
|
||||
|
||||
// ── Thread read-only permalink with OG tags ──
|
||||
routes.get("/thread/:id", async (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const id = c.req.param("id");
|
||||
|
|
@ -1087,12 +1467,33 @@ routes.get("/thread/:id", async (c) => {
|
|||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: renderThreadBuilderPage(space, thread),
|
||||
styles: `<style>${THREAD_CSS}</style>`,
|
||||
body: renderThreadReadOnly(space, thread),
|
||||
styles: `<style>${THREAD_CSS}${THREAD_RO_CSS}</style>`,
|
||||
head: ogHead,
|
||||
}));
|
||||
});
|
||||
|
||||
// ── Thread editor (edit existing) ──
|
||||
routes.get("/thread/:id/edit", async (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const id = c.req.param("id");
|
||||
if (!id || id.includes("..") || id.includes("/")) return c.text("Not found", 404);
|
||||
|
||||
const thread = await loadThread(id);
|
||||
if (!thread) return c.text("Thread not found", 404);
|
||||
|
||||
return c.html(renderShell({
|
||||
title: `Edit: ${thread.title || "Thread"} — rSocials | rSpace`,
|
||||
moduleId: "rsocials",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: renderThreadBuilderPage(space, thread),
|
||||
styles: `<style>${THREAD_CSS}</style>`,
|
||||
}));
|
||||
});
|
||||
|
||||
// ── Thread builder (new) ──
|
||||
routes.get("/thread", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
return c.html(renderShell({
|
||||
|
|
@ -1106,6 +1507,119 @@ routes.get("/thread", (c) => {
|
|||
}));
|
||||
});
|
||||
|
||||
// ── Thread listing / gallery ──
|
||||
const THREADS_LIST_CSS = `
|
||||
.threads-gallery { max-width: 900px; margin: 0 auto; padding: 2rem 1rem; }
|
||||
.threads-gallery__header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 0.75rem; }
|
||||
.threads-gallery__header h1 {
|
||||
margin: 0; font-size: 1.5rem;
|
||||
background: linear-gradient(135deg, #7dd3fc, #c4b5fd);
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
||||
}
|
||||
.threads-gallery__grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem;
|
||||
}
|
||||
.threads-gallery__empty { color: #64748b; text-align: center; padding: 3rem 1rem; font-size: 0.9rem; }
|
||||
.thread-card {
|
||||
background: #1e293b; border: 1px solid #334155; border-radius: 0.75rem;
|
||||
padding: 1.25rem; transition: border-color 0.15s, transform 0.15s;
|
||||
display: flex; flex-direction: column; gap: 0.75rem;
|
||||
text-decoration: none; color: inherit;
|
||||
}
|
||||
.thread-card:hover { border-color: #6366f1; transform: translateY(-2px); }
|
||||
.thread-card__title { font-size: 1rem; font-weight: 700; color: #f1f5f9; margin: 0; line-height: 1.3; }
|
||||
.thread-card__preview {
|
||||
font-size: 0.85rem; color: #94a3b8; line-height: 1.5;
|
||||
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
.thread-card__meta { display: flex; align-items: center; gap: 0.75rem; font-size: 0.75rem; color: #64748b; margin-top: auto; }
|
||||
.thread-card__author { display: flex; align-items: center; gap: 0.4rem; }
|
||||
.thread-card__avatar-sm {
|
||||
width: 20px; height: 20px; border-radius: 50%; background: #6366f1;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: white; font-weight: 700; font-size: 0.55rem; flex-shrink: 0;
|
||||
}
|
||||
.thread-card__image { border-radius: 8px; overflow: hidden; border: 1px solid #334155; margin-bottom: 0.25rem; }
|
||||
.thread-card__image img { display: block; width: 100%; height: 120px; object-fit: cover; }
|
||||
`;
|
||||
|
||||
async function renderThreadsGallery(space: string): Promise<string> {
|
||||
const dir = await ensureThreadsDir();
|
||||
const files = await readdir(dir);
|
||||
const threads: ThreadData[] = [];
|
||||
|
||||
for (const f of files) {
|
||||
if (!f.endsWith(".json")) continue;
|
||||
try {
|
||||
const raw = await readFile(resolve(dir, f), "utf-8");
|
||||
threads.push(JSON.parse(raw));
|
||||
} catch { /* skip corrupt */ }
|
||||
}
|
||||
threads.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
|
||||
if (!threads.length) {
|
||||
return `
|
||||
<div class="threads-gallery">
|
||||
<div class="threads-gallery__header">
|
||||
<h1>Threads</h1>
|
||||
<a href="/${escapeHtml(space)}/rsocials/thread" class="thread-btn thread-btn--primary">New Thread</a>
|
||||
</div>
|
||||
<div class="threads-gallery__empty">
|
||||
<p>No threads yet. Create your first thread!</p>
|
||||
<a href="/${escapeHtml(space)}/rsocials/thread" class="thread-btn thread-btn--success" style="margin-top:1rem;display:inline-flex">Create Thread</a>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const cards = threads.map((t) => {
|
||||
const initial = (t.name || "?").charAt(0).toUpperCase();
|
||||
const preview = escapeHtml((t.tweets[0] || "").substring(0, 200));
|
||||
const dateStr = new Date(t.updatedAt).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
const imageTag = t.imageUrl
|
||||
? `<div class="thread-card__image"><img src="${escapeHtml(t.imageUrl)}" alt="" loading="lazy"></div>`
|
||||
: "";
|
||||
|
||||
return `<a href="/${escapeHtml(space)}/rsocials/thread/${escapeHtml(t.id)}" class="thread-card">
|
||||
${imageTag}
|
||||
<h3 class="thread-card__title">${escapeHtml(t.title || "Untitled Thread")}</h3>
|
||||
<p class="thread-card__preview">${preview}</p>
|
||||
<div class="thread-card__meta">
|
||||
<div class="thread-card__author">
|
||||
<div class="thread-card__avatar-sm">${escapeHtml(initial)}</div>
|
||||
<span>${escapeHtml(t.handle || t.name || "Anonymous")}</span>
|
||||
</div>
|
||||
<span>${t.tweets.length} tweet${t.tweets.length === 1 ? "" : "s"}</span>
|
||||
<span>${dateStr}</span>
|
||||
</div>
|
||||
</a>`;
|
||||
}).join("\n");
|
||||
|
||||
return `
|
||||
<div class="threads-gallery">
|
||||
<div class="threads-gallery__header">
|
||||
<h1>Threads</h1>
|
||||
<a href="/${escapeHtml(space)}/rsocials/thread" class="thread-btn thread-btn--primary">New Thread</a>
|
||||
</div>
|
||||
<div class="threads-gallery__grid">
|
||||
${cards}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
routes.get("/threads", async (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const body = await renderThreadsGallery(space);
|
||||
return c.html(renderShell({
|
||||
title: `Threads — rSocials | rSpace`,
|
||||
moduleId: "rsocials",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body,
|
||||
styles: `<style>${THREAD_CSS}${THREADS_LIST_CSS}</style>`,
|
||||
}));
|
||||
});
|
||||
|
||||
// ── Campaigns redirect (plural → singular) ──
|
||||
routes.get("/campaigns", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
|
|
|
|||
Loading…
Reference in New Issue