617 lines
22 KiB
TypeScript
617 lines
22 KiB
TypeScript
/**
|
|
* Socials module — federated social feed aggregator.
|
|
*
|
|
* Slim mod.ts: Automerge doc management, image API routes,
|
|
* page routes (injecting web components), seed template, module export.
|
|
*
|
|
* All UI moved to web components in components/.
|
|
* Thread/campaign CRUD handled by Automerge (no REST CRUD).
|
|
* File-based threads migrated to Automerge on first access.
|
|
*/
|
|
|
|
import { resolve } from "node:path";
|
|
import { readdir, readFile } from "node:fs/promises";
|
|
import { Hono } from "hono";
|
|
import * as Automerge from "@automerge/automerge";
|
|
import { renderShell, renderExternalAppShell, escapeHtml, RICH_LANDING_CSS } from "../../server/shell";
|
|
import { getModuleInfoList } from "../../shared/module";
|
|
import type { RSpaceModule } from "../../shared/module";
|
|
import type { SyncServer } from "../../server/local-first/sync-server";
|
|
import { renderLanding } from "./landing";
|
|
import { MYCOFI_CAMPAIGN } from "./campaign-data";
|
|
import { socialsSchema, socialsDocId, type SocialsDoc, type ThreadData } from "./schemas";
|
|
import {
|
|
generateImageFromPrompt,
|
|
downloadAndSaveImage,
|
|
validateImageFile,
|
|
safeExtension,
|
|
saveUploadedFile,
|
|
deleteImageFile,
|
|
deleteOldImage,
|
|
} from "./lib/image-gen";
|
|
import { DEMO_FEED } from "./lib/types";
|
|
|
|
let _syncServer: SyncServer | null = null;
|
|
|
|
const routes = new Hono();
|
|
|
|
// ── Automerge doc management ──
|
|
|
|
function ensureDoc(space: string): SocialsDoc {
|
|
const docId = socialsDocId(space);
|
|
let doc = _syncServer!.getDoc<SocialsDoc>(docId);
|
|
if (!doc) {
|
|
doc = Automerge.change(Automerge.init<SocialsDoc>(), "init", (d) => {
|
|
const init = socialsSchema.init();
|
|
d.meta = init.meta;
|
|
d.meta.spaceSlug = space;
|
|
d.threads = {};
|
|
d.campaigns = {};
|
|
});
|
|
_syncServer!.setDoc(docId, doc);
|
|
}
|
|
return doc;
|
|
}
|
|
|
|
function getThreadFromDoc(space: string, id: string): ThreadData | undefined {
|
|
const doc = ensureDoc(space);
|
|
return doc.threads?.[id];
|
|
}
|
|
|
|
// ── Migration: file-based threads → Automerge ──
|
|
|
|
async function migrateFileThreadsToAutomerge(space: string): Promise<void> {
|
|
const doc = ensureDoc(space);
|
|
if (Object.keys(doc.threads || {}).length > 0) return; // Already has threads
|
|
|
|
const threadsDir = resolve(process.env.FILES_DIR || "./data/files", "threads");
|
|
let files: string[];
|
|
try {
|
|
files = await readdir(threadsDir);
|
|
} catch {
|
|
return; // No threads directory
|
|
}
|
|
|
|
let count = 0;
|
|
const docId = socialsDocId(space);
|
|
for (const f of files) {
|
|
if (!f.endsWith(".json")) continue;
|
|
try {
|
|
const raw = await readFile(resolve(threadsDir, f), "utf-8");
|
|
const thread: ThreadData = JSON.parse(raw);
|
|
_syncServer!.changeDoc<SocialsDoc>(docId, `migrate thread ${thread.id}`, (d) => {
|
|
if (!d.threads) d.threads = {} as any;
|
|
d.threads[thread.id] = thread;
|
|
});
|
|
count++;
|
|
} catch { /* skip corrupt files */ }
|
|
}
|
|
|
|
if (count > 0) {
|
|
console.log(`[rSocials] Migrated ${count} file-based threads to Automerge for space "${space}"`);
|
|
}
|
|
}
|
|
|
|
// ── Seed template ──
|
|
|
|
function seedTemplateSocials(space: string): void {
|
|
if (!_syncServer) return;
|
|
const doc = ensureDoc(space);
|
|
|
|
// Seed MYCOFI_CAMPAIGN if no campaigns exist
|
|
if (Object.keys(doc.campaigns || {}).length === 0) {
|
|
const docId = socialsDocId(space);
|
|
const now = Date.now();
|
|
_syncServer.changeDoc<SocialsDoc>(docId, "seed campaign", (d) => {
|
|
if (!d.campaigns) d.campaigns = {} as any;
|
|
d.campaigns[MYCOFI_CAMPAIGN.id] = {
|
|
...MYCOFI_CAMPAIGN,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
});
|
|
}
|
|
|
|
// Seed a sample thread if empty
|
|
if (Object.keys(doc.threads || {}).length === 0) {
|
|
const docId = socialsDocId(space);
|
|
const now = Date.now();
|
|
const threadId = `t-${now}-seed`;
|
|
_syncServer.changeDoc<SocialsDoc>(docId, "seed thread", (d) => {
|
|
if (!d.threads) d.threads = {} as any;
|
|
d.threads[threadId] = {
|
|
id: threadId,
|
|
name: "rSocials",
|
|
handle: "@rsocials",
|
|
title: "Welcome to Thread Builder",
|
|
tweets: [
|
|
"Welcome to the rSocials Thread Builder! Write your thread content here, separated by --- between tweets.",
|
|
"Each section becomes a separate tweet card with live character counts and thread numbering.",
|
|
"When you're ready, export to Twitter, Bluesky, Mastodon, or LinkedIn. All locally stored, no third-party data mining.",
|
|
],
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
});
|
|
console.log(`[rSocials] Template seeded for "${space}": campaign + sample thread`);
|
|
}
|
|
}
|
|
|
|
// ── API: Health & Info ──
|
|
|
|
routes.get("/api/health", (c) => c.json({ ok: true, module: "rsocials" }));
|
|
|
|
routes.get("/api/info", (c) =>
|
|
c.json({
|
|
module: "rsocials",
|
|
description: "Federated social feed aggregator for communities",
|
|
features: ["ActivityPub integration", "RSS feed aggregation", "Link sharing", "Community timeline"],
|
|
}),
|
|
);
|
|
|
|
// ── API: Demo feed ──
|
|
|
|
routes.get("/api/feed", (c) =>
|
|
c.json({
|
|
items: [
|
|
{ id: "demo-1", type: "post", author: "Alice", content: "Just published our community governance proposal!", source: "fediverse", timestamp: new Date(Date.now() - 3600_000).toISOString(), likes: 12, replies: 3 },
|
|
{ id: "demo-2", type: "link", author: "Bob", content: "Great article on local-first collaboration", url: "https://example.com/local-first", source: "shared", timestamp: new Date(Date.now() - 7200_000).toISOString(), likes: 8, replies: 1 },
|
|
{ id: "demo-3", type: "post", author: "Carol", content: "Welcome new members! Check out rSpace's tools in the app switcher above.", source: "local", timestamp: new Date(Date.now() - 14400_000).toISOString(), likes: 24, replies: 7 },
|
|
],
|
|
demo: true,
|
|
}),
|
|
);
|
|
|
|
// ── Image API routes (server-side, need filesystem + FAL_KEY) ──
|
|
|
|
routes.post("/api/threads/:id/image", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const id = c.req.param("id");
|
|
if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400);
|
|
|
|
const thread = getThreadFromDoc(space, id);
|
|
if (!thread) return c.json({ error: "Thread not found" }, 404);
|
|
|
|
if (!process.env.FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);
|
|
|
|
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 cdnUrl = await generateImageFromPrompt(prompt);
|
|
if (!cdnUrl) return c.json({ error: "Image generation failed" }, 502);
|
|
|
|
const filename = `thread-${id}.png`;
|
|
const imageUrl = await downloadAndSaveImage(cdnUrl, filename);
|
|
if (!imageUrl) return c.json({ error: "Failed to download image" }, 502);
|
|
|
|
// Update Automerge doc with image URL
|
|
const docId = socialsDocId(space);
|
|
_syncServer!.changeDoc<SocialsDoc>(docId, "set thread image", (d) => {
|
|
if (d.threads?.[id]) {
|
|
d.threads[id].imageUrl = imageUrl;
|
|
d.threads[id].updatedAt = Date.now();
|
|
}
|
|
});
|
|
|
|
return c.json({ imageUrl });
|
|
});
|
|
|
|
routes.post("/api/threads/:id/upload-image", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const id = c.req.param("id");
|
|
if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400);
|
|
|
|
const thread = getThreadFromDoc(space, 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 err = validateImageFile(file);
|
|
if (err) return c.json({ error: err }, 400);
|
|
|
|
const ext = safeExtension(file.name);
|
|
const filename = `thread-${id}.${ext}`;
|
|
|
|
await deleteOldImage(thread.imageUrl, filename);
|
|
|
|
const buffer = Buffer.from(await file.arrayBuffer());
|
|
const imageUrl = await saveUploadedFile(buffer, filename);
|
|
|
|
const docId = socialsDocId(space);
|
|
_syncServer!.changeDoc<SocialsDoc>(docId, "upload thread image", (d) => {
|
|
if (d.threads?.[id]) {
|
|
d.threads[id].imageUrl = imageUrl;
|
|
d.threads[id].updatedAt = Date.now();
|
|
}
|
|
});
|
|
|
|
return c.json({ imageUrl });
|
|
});
|
|
|
|
routes.post("/api/threads/:id/tweet/:index/upload-image", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
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 = getThreadFromDoc(space, 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 err = validateImageFile(file);
|
|
if (err) return c.json({ error: err }, 400);
|
|
|
|
const ext = safeExtension(file.name);
|
|
const filename = `thread-${id}-tweet-${index}.${ext}`;
|
|
|
|
const oldUrl = thread.tweetImages?.[index];
|
|
if (oldUrl) await deleteOldImage(oldUrl, filename);
|
|
|
|
const buffer = Buffer.from(await file.arrayBuffer());
|
|
const imageUrl = await saveUploadedFile(buffer, filename);
|
|
|
|
const docId = socialsDocId(space);
|
|
_syncServer!.changeDoc<SocialsDoc>(docId, "upload tweet image", (d) => {
|
|
if (d.threads?.[id]) {
|
|
if (!d.threads[id].tweetImages) d.threads[id].tweetImages = {} as any;
|
|
d.threads[id].tweetImages![index] = imageUrl;
|
|
d.threads[id].updatedAt = Date.now();
|
|
}
|
|
});
|
|
|
|
return c.json({ imageUrl });
|
|
});
|
|
|
|
routes.post("/api/threads/:id/tweet/:index/image", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
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 = getThreadFromDoc(space, 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);
|
|
}
|
|
|
|
if (!process.env.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 cdnUrl = await generateImageFromPrompt(prompt);
|
|
if (!cdnUrl) return c.json({ error: "Image generation failed" }, 502);
|
|
|
|
const filename = `thread-${id}-tweet-${index}.png`;
|
|
|
|
const oldUrl = thread.tweetImages?.[index];
|
|
if (oldUrl) await deleteOldImage(oldUrl, filename);
|
|
|
|
const imageUrl = await downloadAndSaveImage(cdnUrl, filename);
|
|
if (!imageUrl) return c.json({ error: "Failed to download image" }, 502);
|
|
|
|
const docId = socialsDocId(space);
|
|
_syncServer!.changeDoc<SocialsDoc>(docId, "generate tweet image", (d) => {
|
|
if (d.threads?.[id]) {
|
|
if (!d.threads[id].tweetImages) d.threads[id].tweetImages = {} as any;
|
|
d.threads[id].tweetImages![index] = imageUrl;
|
|
d.threads[id].updatedAt = Date.now();
|
|
}
|
|
});
|
|
|
|
return c.json({ imageUrl });
|
|
});
|
|
|
|
routes.delete("/api/threads/:id/tweet/:index/image", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
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 = getThreadFromDoc(space, id);
|
|
if (!thread) return c.json({ error: "Thread not found" }, 404);
|
|
|
|
if (!thread.tweetImages?.[index]) return c.json({ ok: true });
|
|
|
|
await deleteImageFile(thread.tweetImages[index]);
|
|
|
|
const docId = socialsDocId(space);
|
|
_syncServer!.changeDoc<SocialsDoc>(docId, "remove tweet image", (d) => {
|
|
if (d.threads?.[id]?.tweetImages?.[index]) {
|
|
delete d.threads[id].tweetImages![index];
|
|
if (Object.keys(d.threads[id].tweetImages || {}).length === 0) {
|
|
delete d.threads[id].tweetImages;
|
|
}
|
|
d.threads[id].updatedAt = Date.now();
|
|
}
|
|
});
|
|
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
routes.delete("/api/threads/:id/images", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const id = c.req.param("id");
|
|
if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400);
|
|
|
|
const thread = getThreadFromDoc(space, id);
|
|
if (!thread) return c.json({ ok: true }); // Thread already gone
|
|
|
|
// Clean up header image
|
|
if (thread.imageUrl) await deleteImageFile(thread.imageUrl);
|
|
|
|
// Clean up per-tweet images
|
|
if (thread.tweetImages) {
|
|
for (const url of Object.values(thread.tweetImages)) {
|
|
await deleteImageFile(url);
|
|
}
|
|
}
|
|
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
// ── Page routes (inject web components) ──
|
|
|
|
routes.get("/campaign", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
return c.html(renderShell({
|
|
title: `Campaign — rSocials | rSpace`,
|
|
moduleId: "rsocials",
|
|
spaceSlug: space,
|
|
modules: getModuleInfoList(),
|
|
theme: "dark",
|
|
body: `<folk-campaign-manager space="${escapeHtml(space)}"></folk-campaign-manager>`,
|
|
styles: `<link rel="stylesheet" href="/modules/rsocials/socials.css">`,
|
|
scripts: `<script type="module" src="/modules/rsocials/folk-campaign-manager.js"></script>`,
|
|
}));
|
|
});
|
|
|
|
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 = getThreadFromDoc(space, id);
|
|
if (!thread) return c.text("Thread not found", 404);
|
|
|
|
// OG tags for social crawlers (SSR)
|
|
const desc = escapeHtml((thread.tweets[0] || "").substring(0, 200));
|
|
const titleText = escapeHtml(`Thread by ${thread.handle}`);
|
|
const origin = "https://rspace.online";
|
|
|
|
let ogHead = `
|
|
<meta property="og:title" content="${titleText}">
|
|
<meta property="og:description" content="${desc}">
|
|
<meta property="og:type" content="article">
|
|
<meta name="twitter:card" content="summary_large_image">
|
|
<meta name="twitter:title" content="${titleText}">
|
|
<meta name="twitter:description" content="${desc}">`;
|
|
if (thread.imageUrl) {
|
|
ogHead += `
|
|
<meta property="og:image" content="${origin}${thread.imageUrl}">
|
|
<meta name="twitter:image" content="${origin}${thread.imageUrl}">`;
|
|
}
|
|
|
|
// Hydrate thread data for the component
|
|
const dataScript = `<script>window.__THREAD_DATA__ = ${JSON.stringify(thread).replace(/</g, "\\u003c")};</script>`;
|
|
|
|
return c.html(renderShell({
|
|
title: `${thread.title || "Thread"} — rSocials | rSpace`,
|
|
moduleId: "rsocials",
|
|
spaceSlug: space,
|
|
modules: getModuleInfoList(),
|
|
theme: "dark",
|
|
body: `${dataScript}<folk-thread-builder space="${escapeHtml(space)}" thread-id="${escapeHtml(id)}" mode="readonly"></folk-thread-builder>`,
|
|
styles: `<link rel="stylesheet" href="/modules/rsocials/socials.css">`,
|
|
scripts: `<script type="module" src="/modules/rsocials/folk-thread-builder.js"></script>`,
|
|
head: ogHead,
|
|
}));
|
|
});
|
|
|
|
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 = getThreadFromDoc(space, id);
|
|
if (!thread) return c.text("Thread not found", 404);
|
|
|
|
const dataScript = `<script>window.__THREAD_DATA__ = ${JSON.stringify(thread).replace(/</g, "\\u003c")};</script>`;
|
|
|
|
return c.html(renderShell({
|
|
title: `Edit: ${thread.title || "Thread"} — rSocials | rSpace`,
|
|
moduleId: "rsocials",
|
|
spaceSlug: space,
|
|
modules: getModuleInfoList(),
|
|
theme: "dark",
|
|
body: `${dataScript}<folk-thread-builder space="${escapeHtml(space)}" thread-id="${escapeHtml(id)}" mode="edit"></folk-thread-builder>`,
|
|
styles: `<link rel="stylesheet" href="/modules/rsocials/socials.css">`,
|
|
scripts: `<script type="module" src="/modules/rsocials/folk-thread-builder.js"></script>`,
|
|
}));
|
|
});
|
|
|
|
routes.get("/thread", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
return c.html(renderShell({
|
|
title: `Thread Builder — rSocials | rSpace`,
|
|
moduleId: "rsocials",
|
|
spaceSlug: space,
|
|
modules: getModuleInfoList(),
|
|
theme: "dark",
|
|
body: `<folk-thread-builder space="${escapeHtml(space)}" mode="new"></folk-thread-builder>`,
|
|
styles: `<link rel="stylesheet" href="/modules/rsocials/socials.css">`,
|
|
scripts: `<script type="module" src="/modules/rsocials/folk-thread-builder.js"></script>`,
|
|
}));
|
|
});
|
|
|
|
routes.get("/threads", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
return c.html(renderShell({
|
|
title: `Threads — rSocials | rSpace`,
|
|
moduleId: "rsocials",
|
|
spaceSlug: space,
|
|
modules: getModuleInfoList(),
|
|
theme: "dark",
|
|
body: `<folk-thread-gallery space="${escapeHtml(space)}"></folk-thread-gallery>`,
|
|
styles: `<link rel="stylesheet" href="/modules/rsocials/socials.css">`,
|
|
scripts: `<script type="module" src="/modules/rsocials/folk-thread-gallery.js"></script>`,
|
|
}));
|
|
});
|
|
|
|
routes.get("/campaigns", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
return c.redirect(`/${space}/rsocials/campaign`);
|
|
});
|
|
|
|
// ── Demo feed rendering (server-rendered, no web component needed) ──
|
|
|
|
function renderDemoFeedHTML(): string {
|
|
const cards = DEMO_FEED.map(
|
|
(post) => `
|
|
<article class="rsocials-item">
|
|
<div class="rsocials-item-header">
|
|
<div class="rsocials-avatar" style="background:${post.color}">${post.initial}</div>
|
|
<div class="rsocials-meta">
|
|
<strong>${post.username}</strong>
|
|
<time>${post.timeAgo}</time>
|
|
</div>
|
|
</div>
|
|
<p class="rsocials-item-content">${post.content}</p>
|
|
<div class="rsocials-item-actions">
|
|
<span class="rsocials-action"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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> ${post.likes}</span>
|
|
<span class="rsocials-action"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg> ${post.replies}</span>
|
|
</div>
|
|
</article>`,
|
|
).join("\n");
|
|
|
|
return `
|
|
<div class="rsocials-app rsocials-demo">
|
|
<div class="rsocials-header">
|
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:1rem">
|
|
<h2>Social Feed <span class="rsocials-demo-badge">DEMO</span></h2>
|
|
<a href="?view=app" class="rapp-nav__btn--app-toggle">Open Full App</a>
|
|
</div>
|
|
<p class="rsocials-subtitle">A preview of your community's social timeline</p>
|
|
</div>
|
|
<div class="rsocials-feed">${cards}</div>
|
|
<p class="rsocials-demo-notice">This is demo data. Connect ActivityPub or RSS feeds in your own space.</p>
|
|
</div>`;
|
|
}
|
|
|
|
// ── Main page route ──
|
|
|
|
routes.get("/", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const view = c.req.query("view");
|
|
|
|
if (view === "app") {
|
|
return c.html(renderExternalAppShell({
|
|
title: `${space} — Postiz | rSpace`,
|
|
moduleId: "rsocials",
|
|
spaceSlug: space,
|
|
modules: getModuleInfoList(),
|
|
appUrl: "https://social.jeffemmett.com",
|
|
appName: "Postiz",
|
|
theme: "dark",
|
|
}));
|
|
}
|
|
|
|
const isDemo = space === "demo";
|
|
const body = isDemo ? renderDemoFeedHTML() : renderLanding();
|
|
const styles = isDemo
|
|
? `<link rel="stylesheet" href="/modules/rsocials/socials.css">`
|
|
: `<style>${RICH_LANDING_CSS}</style>`;
|
|
|
|
return c.html(renderShell({
|
|
title: `${space} — Socials | rSpace`,
|
|
moduleId: "rsocials",
|
|
spaceSlug: space,
|
|
modules: getModuleInfoList(),
|
|
theme: "dark",
|
|
body,
|
|
styles,
|
|
}));
|
|
});
|
|
|
|
// ── Module export ──
|
|
|
|
export const socialsModule: RSpaceModule = {
|
|
id: "rsocials",
|
|
name: "rSocials",
|
|
icon: "📢",
|
|
description: "Federated social feed aggregator for communities",
|
|
scoping: { defaultScope: "global", userConfigurable: true },
|
|
docSchemas: [{ pattern: "{space}:socials:data", description: "Threads and campaigns", init: socialsSchema.init }],
|
|
routes,
|
|
publicWrite: true,
|
|
standaloneDomain: "rsocials.online",
|
|
landingPage: renderLanding,
|
|
seedTemplate: seedTemplateSocials,
|
|
async onInit(ctx) {
|
|
_syncServer = ctx.syncServer;
|
|
// Run migration for any existing file-based threads
|
|
try { await migrateFileThreadsToAutomerge("demo"); } catch { /* ignore */ }
|
|
},
|
|
externalApp: { url: "https://social.jeffemmett.com", name: "Postiz" },
|
|
feeds: [
|
|
{
|
|
id: "social-feed",
|
|
name: "Social Feed",
|
|
kind: "data",
|
|
description: "Community social timeline — posts, links, and activity from connected platforms",
|
|
},
|
|
],
|
|
acceptsFeeds: ["data", "trust"],
|
|
outputPaths: [
|
|
{ path: "campaigns", name: "Campaigns", icon: "📢", description: "Social media campaigns" },
|
|
{ path: "posts", name: "Posts", icon: "📱", description: "Social feed posts across platforms" },
|
|
],
|
|
subPageInfos: [
|
|
{
|
|
path: "thread",
|
|
title: "Thread Builder",
|
|
icon: "🧵",
|
|
tagline: "rSocials Tool",
|
|
description: "Compose, preview, and schedule tweet threads with a live card-by-card preview. Save drafts, generate share images, and publish when ready.",
|
|
features: [
|
|
{ icon: "✍️", title: "Live Preview", text: "See your thread as tweet cards in real time as you type, with character counts and thread numbering." },
|
|
{ icon: "💾", title: "Save & Edit Drafts", text: "Save thread drafts to your space, revisit and refine them before publishing." },
|
|
{ icon: "🖼️", title: "Share Images", text: "Auto-generate a branded share image of your thread for cross-posting." },
|
|
],
|
|
},
|
|
{
|
|
path: "campaign",
|
|
title: "Campaign Manager",
|
|
icon: "📢",
|
|
tagline: "rSocials Tool",
|
|
description: "Plan and track multi-platform social media campaigns with scheduling, analytics, and team collaboration.",
|
|
features: [
|
|
{ icon: "📅", title: "Schedule Posts", text: "Queue posts across platforms with a visual calendar timeline." },
|
|
{ icon: "📊", title: "Track Performance", text: "Monitor engagement metrics and campaign reach in one dashboard." },
|
|
{ icon: "👥", title: "Team Workflow", text: "Draft, review, and approve posts collaboratively before publishing." },
|
|
],
|
|
},
|
|
{
|
|
path: "threads",
|
|
title: "Thread Gallery",
|
|
icon: "📋",
|
|
tagline: "rSocials Tool",
|
|
description: "Browse all saved thread drafts in your community. Find inspiration, remix threads, or pick up where you left off.",
|
|
},
|
|
],
|
|
};
|