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 ``;
+ }).join("\n");
+
+ const imageHTML = thread.imageUrl
+ ? `
})
`
+ : "";
+
+ return `
+
+
+ ${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}
+
+ `;
+ }).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";