diff --git a/modules/rsocials/mod.ts b/modules/rsocials/mod.ts index 6146357..65fe7fb 100644 --- a/modules/rsocials/mod.ts +++ b/modules/rsocials/mod.ts @@ -5,6 +5,8 @@ * Supports ActivityPub, RSS, and manual link sharing. */ +import { resolve } from "node:path"; +import { mkdir, readdir, readFile, writeFile, unlink } from "node:fs/promises"; import { Hono } from "hono"; import { renderShell, renderExternalAppShell, escapeHtml } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; @@ -14,6 +16,71 @@ import { MYCOFI_CAMPAIGN, PLATFORM_ICONS, PLATFORM_COLORS } from "./campaign-dat const routes = new Hono(); +// ── Thread storage helpers ── +const THREADS_BASE = resolve(process.env.FILES_DIR || "./data/files", "threads"); + +interface ThreadData { + id: string; + name: string; + handle: string; + title: string; + tweets: string[]; + imageUrl?: string; + createdAt: number; + updatedAt: number; +} + +function generateThreadId(): string { + const random = Math.random().toString(36).substring(2, 8); + return `t-${Date.now()}-${random}`; +} + +async function ensureThreadsDir(): Promise { + await mkdir(THREADS_BASE, { recursive: true }); + return THREADS_BASE; +} + +async function loadThread(id: string): Promise { + try { + const dir = await ensureThreadsDir(); + const raw = await readFile(resolve(dir, `${id}.json`), "utf-8"); + return JSON.parse(raw); + } catch { + return null; + } +} + +async function saveThread(data: ThreadData): Promise { + const dir = await ensureThreadsDir(); + await writeFile(resolve(dir, `${data.id}.json`), JSON.stringify(data, null, 2)); +} + +async function deleteThreadFile(id: string): Promise { + try { + const dir = await ensureThreadsDir(); + await unlink(resolve(dir, `${id}.json`)); + return true; + } catch { + return false; + } +} + +async function listThreads(): Promise> { + const dir = await ensureThreadsDir(); + const files = await readdir(dir); + const threads: Array<{ id: string; title: string; tweetCount: number; updatedAt: number }> = []; + for (const f of files) { + if (!f.endsWith(".json")) continue; + try { + const raw = await readFile(resolve(dir, f), "utf-8"); + const data: ThreadData = JSON.parse(raw); + threads.push({ id: data.id, title: data.title, tweetCount: data.tweets.length, updatedAt: data.updatedAt }); + } catch { /* skip corrupt files */ } + } + threads.sort((a, b) => b.updatedAt - a.updatedAt); + return threads; +} + // ── API: Health ── routes.get("/api/health", (c) => { return c.json({ ok: true, module: "rsocials" }); @@ -94,6 +161,130 @@ routes.get("/api/campaigns", (c) => { }); }); +// ── API: Thread CRUD ── +routes.get("/api/threads", async (c) => { + const threads = await listThreads(); + return c.json({ threads }); +}); + +routes.post("/api/threads", async (c) => { + const { name, handle, title, tweets } = await c.req.json(); + if (!tweets?.length) return c.json({ error: "tweets required" }, 400); + + const id = generateThreadId(); + const now = Date.now(); + const thread: ThreadData = { + id, + name: name || "Your Name", + handle: handle || "@yourhandle", + title: title || (tweets[0] || "").substring(0, 60), + tweets, + createdAt: now, + updatedAt: now, + }; + await saveThread(thread); + return c.json({ id }); +}); + +routes.get("/api/threads/:id", 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); + return c.json(thread); +}); + +routes.put("/api/threads/:id", async (c) => { + const id = c.req.param("id"); + if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400); + const existing = await loadThread(id); + if (!existing) return c.json({ error: "Thread not found" }, 404); + + const { name, handle, title, tweets } = 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; + existing.updatedAt = Date.now(); + + await saveThread(existing); + return c.json({ id }); +}); + +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 + const thread = await loadThread(id); + 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 {} + } + } + + const ok = await deleteThreadFile(id); + if (!ok) return c.json({ error: "Thread not found" }, 404); + return c.json({ ok: true }); +}); + +routes.post("/api/threads/:id/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); + + const FAL_KEY = process.env.FAL_KEY || ""; + if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503); + + // Build prompt from first 2-3 tweets + const summary = thread.tweets.slice(0, 3).join(" ").substring(0, 200); + const prompt = `Social media thread preview card about: ${summary}. 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("[thread-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); + + // Download and save locally + 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}.png`; + await writeFile(resolve(genDir, filename), Buffer.from(imgBuffer)); + + 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 = [ { @@ -399,13 +590,17 @@ routes.get("/campaign", (c) => { // ── Thread Builder ── const THREAD_CSS = ` .thread-page { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; max-width: 1100px; margin: 0 auto; padding: 2rem 1rem; min-height: 80vh; } -.thread-page__header { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; } +.thread-page__header { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0.5rem; } .thread-page__header h1 { margin: 0; font-size: 1.5rem; color: #f1f5f9; background: linear-gradient(135deg, #7dd3fc, #c4b5fd); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } +.thread-page__actions { display: flex; gap: 0.5rem; flex-wrap: wrap; } .thread-btn { padding: 0.5rem 1rem; border-radius: 8px; border: none; font-size: 0.85rem; font-weight: 600; cursor: pointer; transition: all 0.15s; } .thread-btn--primary { background: #6366f1; color: white; } .thread-btn--primary:hover { background: #818cf8; } .thread-btn--outline { background: transparent; color: #94a3b8; border: 1px solid #334155; } .thread-btn--outline:hover { border-color: #6366f1; color: #c4b5fd; } +.thread-btn--success { background: #10b981; color: white; } +.thread-btn--success:hover { background: #34d399; } +.thread-btn:disabled { opacity: 0.5; cursor: not-allowed; } .thread-compose { position: sticky; top: 1rem; align-self: start; display: flex; flex-direction: column; gap: 1rem; } .thread-compose__textarea { width: 100%; min-height: 320px; background: #1e293b; color: #e2e8f0; border: 1px solid #334155; @@ -421,6 +616,49 @@ const THREAD_CSS = ` } .thread-compose__input:focus { outline: none; border-color: #6366f1; } .thread-compose__input::placeholder { color: #475569; } +.thread-compose__title { + width: 100%; background: #1e293b; color: #e2e8f0; border: 1px solid #334155; + border-radius: 8px; padding: 0.5rem 0.75rem; font-size: 0.85rem; box-sizing: border-box; +} +.thread-compose__title:focus { outline: none; border-color: #6366f1; } +.thread-compose__title::placeholder { color: #475569; } +.thread-drafts { grid-column: 1 / -1; } +.thread-drafts__toggle { cursor: pointer; user-select: none; } +.thread-drafts__list { + display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 0.5rem; + margin-top: 0.75rem; +} +.thread-drafts__list[hidden] { display: none; } +.thread-drafts__empty { color: #475569; font-size: 0.8rem; padding: 0.5rem 0; } +.thread-draft-item { + display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; + background: #1e293b; border: 1px solid #334155; border-radius: 8px; + transition: border-color 0.15s; cursor: pointer; +} +.thread-draft-item:hover { border-color: #6366f1; } +.thread-draft-item--active { border-color: #6366f1; background: rgba(99,102,241,0.1); } +.thread-draft-item__info { flex: 1; min-width: 0; } +.thread-draft-item__info strong { display: block; font-size: 0.8rem; color: #e2e8f0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.thread-draft-item__info span { font-size: 0.7rem; color: #64748b; } +.thread-draft-item__delete { + background: none; border: none; color: #64748b; font-size: 1.2rem; cursor: pointer; + padding: 0 4px; line-height: 1; flex-shrink: 0; +} +.thread-draft-item__delete:hover { color: #ef4444; } +.thread-image-section { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; } +.thread-image-preview { border-radius: 8px; overflow: hidden; border: 1px solid #334155; } +.thread-image-preview[hidden] { display: none; } +.thread-image-preview img { display: block; max-width: 200px; height: auto; } +.thread-share-link { + display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.75rem; + background: rgba(99,102,241,0.1); border: 1px solid #6366f1; border-radius: 8px; + font-size: 0.8rem; color: #c4b5fd; +} +.thread-share-link code { font-size: 0.75rem; color: #7dd3fc; } +.thread-share-link button { + background: none; border: none; color: #94a3b8; cursor: pointer; font-size: 0.75rem; padding: 2px 6px; +} +.thread-share-link button:hover { color: #e2e8f0; } .thread-preview { display: flex; flex-direction: column; gap: 0; } .thread-preview__empty { color: #475569; text-align: center; padding: 3rem 1rem; font-size: 0.9rem; } .tweet-card { @@ -458,30 +696,66 @@ const THREAD_CSS = ` } `; -function renderThreadBuilderPage(space: string): string { +function renderThreadBuilderPage(space: string, threadData?: ThreadData | null): string { + const dataScript = threadData + ? `` + : ""; + const basePath = `/${space}/rsocials/`; + return ` + ${dataScript} +

Thread Builder

- +
+ + + +
+
+ + +
+
+ +
+ + +
Your tweet thread preview will appear here
`; } +// ── Thread permalink with OG tags ── +routes.get("/thread/:id", 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); + + const desc = escapeHtml((thread.tweets[0] || "").substring(0, 200)); + const titleText = escapeHtml(`Thread by ${thread.handle}`); + const origin = "https://rspace.online"; + + let ogHead = ` + + + + + + `; + + if (thread.imageUrl) { + ogHead += ` + + `; + } + + return c.html(renderShell({ + title: `${thread.title || "Thread"} — rSocials | rSpace`, + moduleId: "rsocials", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "dark", + body: renderThreadBuilderPage(space, thread), + styles: ``, + head: ogHead, + })); +}); + routes.get("/thread", (c) => { const space = c.req.param("space") || "demo"; return c.html(renderShell({