diff --git a/modules/rsocials/mod.ts b/modules/rsocials/mod.ts index 65fe7fb..565eac4 100644 --- a/modules/rsocials/mod.ts +++ b/modules/rsocials/mod.ts @@ -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): +
+ + +
@@ -727,7 +805,9 @@ function renderThreadBuilderPage(space: string, threadData?: ThreadData | null):
- + + + @@ -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): `; } -// ── 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 ? '
' : ""; + return `
+ ${connector} +
+
${escapeHtml(initial)}
+ ${name} + ${handle} + · + ${escapeHtml(dateStr)} +
+

${escapeHtml(text)}

+ +
`; + }).join("\n"); + + const imageHTML = thread.imageUrl + ? `
Thread preview
` + : ""; + + return ` +
+
+
+
${escapeHtml(initial)}
+
+
${name}
+
${handle}
+
+
+
+ ${total} tweet${total === 1 ? "" : "s"} + · + ${escapeHtml(dateStr)} +
+
+ ${thread.title ? `

${escapeHtml(thread.title)}

` : ""} + ${imageHTML} +
+ ${tweetCards} +
+
+ Edit Thread + + +
+ + +
+
+ +
+ + `; +} + +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: ``, + body: renderThreadReadOnly(space, thread), + styles: ``, 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: ``, + })); +}); + +// ── 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 { + 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 ` + `; + } + + 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 + ? `
` + : ""; + + return ` + ${imageTag} +

${escapeHtml(t.title || "Untitled Thread")}

+

${preview}

+
+
+
${escapeHtml(initial)}
+ ${escapeHtml(t.handle || t.name || "Anonymous")} +
+ ${t.tweets.length} tweet${t.tweets.length === 1 ? "" : "s"} + ${dateStr} +
+
`; + }).join("\n"); + + return ` + `; +} + +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: ``, + })); +}); + // ── Campaigns redirect (plural → singular) ── routes.get("/campaigns", (c) => { const space = c.req.param("space") || "demo";