From 93e8ce84799e6fb072e605bb3970c1711d528009 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 4 Mar 2026 13:17:51 -0800 Subject: [PATCH] feat: per-tweet image upload/generate in Thread Builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each tweet in a thread can now have its own image — uploadable or AI-generated via inline buttons in the preview cards. Images persist across save/load, display in read-only view, and clean up on delete. Co-Authored-By: Claude Opus 4.6 --- modules/rsocials/mod.ts | 277 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 274 insertions(+), 3 deletions(-) diff --git a/modules/rsocials/mod.ts b/modules/rsocials/mod.ts index 52da061..0063ae8 100644 --- a/modules/rsocials/mod.ts +++ b/modules/rsocials/mod.ts @@ -26,6 +26,7 @@ interface ThreadData { title: string; tweets: string[]; imageUrl?: string; + tweetImages?: Record; createdAt: number; updatedAt: number; } @@ -200,11 +201,12 @@ routes.put("/api/threads/:id", async (c) => { const existing = await loadThread(id); if (!existing) return c.json({ error: "Thread not found" }, 404); - const { name, handle, title, tweets } = await c.req.json(); + const { name, handle, title, tweets, tweetImages } = await c.req.json(); if (name !== undefined) existing.name = name; if (handle !== undefined) existing.handle = handle; if (title !== undefined) existing.title = title; if (tweets?.length) existing.tweets = tweets; + if (tweetImages !== undefined) existing.tweetImages = tweetImages; existing.updatedAt = Date.now(); await saveThread(existing); @@ -215,15 +217,24 @@ routes.delete("/api/threads/:id", async (c) => { const id = c.req.param("id"); if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400); - // Try to delete associated preview image + // Try to delete associated images const thread = await loadThread(id); + const genDir = resolve(process.env.FILES_DIR || "./data/files", "generated"); if (thread?.imageUrl) { const filename = thread.imageUrl.split("/").pop(); if (filename) { - const genDir = resolve(process.env.FILES_DIR || "./data/files", "generated"); try { await unlink(resolve(genDir, filename)); } catch {} } } + // Delete per-tweet images + if (thread?.tweetImages) { + for (const url of Object.values(thread.tweetImages)) { + const fname = url.split("/").pop(); + if (fname) { + try { await unlink(resolve(genDir, fname)); } catch {} + } + } + } const ok = await deleteThreadFile(id); if (!ok) return c.json({ error: "Thread not found" }, 404); @@ -338,6 +349,162 @@ routes.post("/api/threads/:id/upload-image", async (c) => { return c.json({ imageUrl }); }); +// ── Per-tweet image endpoints ── + +routes.post("/api/threads/:id/tweet/:index/upload-image", async (c) => { + const id = c.req.param("id"); + const index = c.req.param("index"); + if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400); + if (!/^\d+$/.test(index)) return c.json({ error: "Invalid index" }, 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; + 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}-tweet-${index}.${safeExt}`; + + const genDir = resolve(process.env.FILES_DIR || "./data/files", "generated"); + await mkdir(genDir, { recursive: true }); + + // Delete old image at this index if replacing + if (!thread.tweetImages) thread.tweetImages = {}; + const oldUrl = thread.tweetImages[index]; + if (oldUrl) { + const oldFilename = oldUrl.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.tweetImages[index] = imageUrl; + thread.updatedAt = Date.now(); + await saveThread(thread); + + return c.json({ imageUrl }); +}); + +routes.post("/api/threads/:id/tweet/:index/image", async (c) => { + const id = c.req.param("id"); + const index = c.req.param("index"); + if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400); + if (!/^\d+$/.test(index)) return c.json({ error: "Invalid index" }, 400); + + const thread = await loadThread(id); + if (!thread) return c.json({ error: "Thread not found" }, 404); + + const tweetIndex = parseInt(index, 10); + if (tweetIndex < 0 || tweetIndex >= thread.tweets.length) { + return c.json({ error: "Tweet index out of range" }, 400); + } + + const FAL_KEY = process.env.FAL_KEY || ""; + if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503); + + const tweetText = thread.tweets[tweetIndex].substring(0, 200); + const prompt = `Social media post image about: ${tweetText}. Dark themed, modern, minimal style with abstract shapes.`; + + const falRes = await fetch("https://fal.run/fal-ai/flux-pro/v1.1", { + method: "POST", + headers: { + Authorization: `Key ${FAL_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + prompt, + image_size: "landscape_4_3", + num_images: 1, + safety_tolerance: "2", + }), + }); + + if (!falRes.ok) { + console.error("[tweet-image] fal.ai error:", await falRes.text()); + return c.json({ error: "Image generation failed" }, 502); + } + + const falData = await falRes.json() as { images?: { url: string }[]; output?: { url: string } }; + const cdnUrl = falData.images?.[0]?.url || falData.output?.url; + if (!cdnUrl) return c.json({ error: "No image returned" }, 502); + + const imgRes = await fetch(cdnUrl); + if (!imgRes.ok) return c.json({ error: "Failed to download image" }, 502); + + const imgBuffer = await imgRes.arrayBuffer(); + const genDir = resolve(process.env.FILES_DIR || "./data/files", "generated"); + await mkdir(genDir, { recursive: true }); + const filename = `thread-${id}-tweet-${index}.png`; + + // Delete old image at this index if replacing + if (!thread.tweetImages) thread.tweetImages = {}; + const oldUrl = thread.tweetImages[index]; + if (oldUrl) { + const oldFilename = oldUrl.split("/").pop(); + if (oldFilename && oldFilename !== filename) { + try { await unlink(resolve(genDir, oldFilename)); } catch {} + } + } + + await writeFile(resolve(genDir, filename), Buffer.from(imgBuffer)); + + const imageUrl = `/data/files/generated/${filename}`; + thread.tweetImages[index] = imageUrl; + thread.updatedAt = Date.now(); + await saveThread(thread); + + return c.json({ imageUrl }); +}); + +routes.delete("/api/threads/:id/tweet/:index/image", async (c) => { + const id = c.req.param("id"); + const index = c.req.param("index"); + if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400); + if (!/^\d+$/.test(index)) return c.json({ error: "Invalid index" }, 400); + + const thread = await loadThread(id); + if (!thread) return c.json({ error: "Thread not found" }, 404); + + if (!thread.tweetImages?.[index]) return c.json({ ok: true }); + + const url = thread.tweetImages[index]; + const fname = url.split("/").pop(); + if (fname) { + const genDir = resolve(process.env.FILES_DIR || "./data/files", "generated"); + try { await unlink(resolve(genDir, fname)); } catch {} + } + + delete thread.tweetImages[index]; + if (Object.keys(thread.tweetImages).length === 0) delete thread.tweetImages; + thread.updatedAt = Date.now(); + await saveThread(thread); + + return c.json({ ok: true }); +}); + // ── Demo feed data (server-rendered, no API calls) ── const DEMO_FEED = [ { @@ -763,6 +930,25 @@ const THREAD_CSS = ` } .thread-export-menu button:hover { background: rgba(99,102,241,0.15); } .thread-export-menu button + button { border-top: 1px solid var(--rs-bg-hover); } +.tweet-card__image-bar { display: flex; gap: 0.4rem; margin-top: 0.5rem; } +.tweet-card__image-btn { + padding: 0.25rem 0.5rem; border-radius: 6px; font-size: 0.7rem; font-weight: 600; + cursor: pointer; transition: all 0.15s; background: transparent; + color: var(--rs-text-muted); border: 1px solid var(--rs-input-border); + display: inline-flex; align-items: center; gap: 0.25rem; +} +.tweet-card__image-btn:hover { border-color: #6366f1; color: #c4b5fd; } +.tweet-card__image-btn:disabled { opacity: 0.5; cursor: not-allowed; } +.tweet-card__image-btn svg { width: 12px; height: 12px; } +.tweet-card__attached-image { position: relative; margin-top: 0.5rem; border-radius: 8px; overflow: hidden; border: 1px solid var(--rs-input-border); } +.tweet-card__attached-image img { display: block; width: 100%; height: auto; } +.tweet-card__image-remove { + position: absolute; top: 6px; right: 6px; width: 22px; height: 22px; + border-radius: 50%; background: rgba(0,0,0,0.7); color: white; border: none; + font-size: 0.8rem; cursor: pointer; display: flex; align-items: center; + justify-content: center; line-height: 1; transition: background 0.15s; +} +.tweet-card__image-remove:hover { background: #ef4444; } `; function renderThreadBuilderPage(space: string, threadData?: ThreadData | null): string { @@ -817,6 +1003,7 @@ function renderThreadBuilderPage(space: string, threadData?: ThreadData | null):
Your tweet thread preview will appear here
+