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:
Jeff Emmett 2026-03-03 19:12:14 -08:00
parent cdfe8c5b78
commit 20b75c999d
1 changed files with 528 additions and 14 deletions

View File

@ -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 &#9662;</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">&#183;</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>&#183;</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 &#9662;</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";