Merge branch 'dev'
# Conflicts: # modules/rsocials/mod.ts # server/index.ts
This commit is contained in:
commit
a444cb804e
|
|
@ -136,6 +136,10 @@ services:
|
|||
- "traefik.http.routers.rspace-rswag.entrypoints=web"
|
||||
- "traefik.http.routers.rspace-rswag.priority=120"
|
||||
- "traefik.http.routers.rspace-rswag.service=rspace-online"
|
||||
- "traefik.http.routers.rspace-rsocials.rule=Host(`rsocials.online`)"
|
||||
- "traefik.http.routers.rspace-rsocials.entrypoints=web"
|
||||
- "traefik.http.routers.rspace-rsocials.priority=120"
|
||||
- "traefik.http.routers.rspace-rsocials.service=rspace-online"
|
||||
# Service configuration
|
||||
- "traefik.http.services.rspace-online.loadbalancer.server.port=3000"
|
||||
- "traefik.docker.network=traefik-public"
|
||||
|
|
|
|||
|
|
@ -1,120 +1,199 @@
|
|||
/**
|
||||
* rSocials module — campaign strategy workflow builder.
|
||||
* Socials module — federated social feed aggregator.
|
||||
*
|
||||
* Proxies campaign API + embeds the Next.js campaign editor from rsocials:3000.
|
||||
* Page routes render an iframe inside the rSpace shell so the campaign builder
|
||||
* gets the full rSpace header/nav while the Next.js app runs independently.
|
||||
* Aggregates and displays social media activity across community members.
|
||||
* Supports ActivityPub, RSS, and manual link sharing.
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { renderShell } from "../../server/shell";
|
||||
import type { RSpaceModule } from "../../shared/module";
|
||||
import { getModuleInfoList } from "../../shared/module";
|
||||
|
||||
const RSOCIALS_INTERNAL = process.env.RSOCIALS_URL || "http://rsocials:3000";
|
||||
const RSOCIALS_PUBLIC = "https://rsocials.online";
|
||||
import type { RSpaceModule } from "../../shared/module";
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
// ── API proxy ────────────────────────────────────────────
|
||||
// Proxy all campaign API calls to the rsocials container
|
||||
|
||||
routes.get("/api/campaigns", async (c) => {
|
||||
const res = await fetch(`${RSOCIALS_INTERNAL}/api/campaigns`);
|
||||
return c.json(await res.json(), res.status as StatusCode);
|
||||
// ── API: Health ──
|
||||
routes.get("/api/health", (c) => {
|
||||
return c.json({ ok: true, module: "rsocials" });
|
||||
});
|
||||
|
||||
routes.get("/api/campaigns/:id", async (c) => {
|
||||
const res = await fetch(`${RSOCIALS_INTERNAL}/api/campaigns/${c.req.param("id")}`);
|
||||
return c.json(await res.json(), res.status as StatusCode);
|
||||
});
|
||||
|
||||
routes.post("/api/campaigns", async (c) => {
|
||||
const body = await c.req.text();
|
||||
const res = await fetch(`${RSOCIALS_INTERNAL}/api/campaigns`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
// ── API: Info ──
|
||||
routes.get("/api/info", (c) => {
|
||||
return c.json({
|
||||
module: "rsocials",
|
||||
description: "Federated social feed aggregator for communities",
|
||||
features: [
|
||||
"ActivityPub integration",
|
||||
"RSS feed aggregation",
|
||||
"Link sharing",
|
||||
"Community timeline",
|
||||
],
|
||||
});
|
||||
return c.json(await res.json(), res.status as StatusCode);
|
||||
});
|
||||
|
||||
routes.put("/api/campaigns/:id", async (c) => {
|
||||
const body = await c.req.text();
|
||||
const res = await fetch(`${RSOCIALS_INTERNAL}/api/campaigns/${c.req.param("id")}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
// ── API: Feed — community social timeline ──
|
||||
routes.get("/api/feed", (c) => {
|
||||
// Demo feed items
|
||||
return 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,
|
||||
});
|
||||
return c.json(await res.json(), res.status as StatusCode);
|
||||
});
|
||||
|
||||
routes.delete("/api/campaigns/:id", async (c) => {
|
||||
const res = await fetch(`${RSOCIALS_INTERNAL}/api/campaigns/${c.req.param("id")}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
return c.json(await res.json(), res.status as StatusCode);
|
||||
});
|
||||
|
||||
// ── Page routes ──────────────────────────────────────────
|
||||
|
||||
function campaignFrame(src: string, space: string, title: string) {
|
||||
return renderShell({
|
||||
title,
|
||||
moduleId: "rsocials",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
styles: `<style>
|
||||
main.rsocials-frame { padding: 0 !important; margin: 0; }
|
||||
.rsocials-iframe {
|
||||
width: 100%; border: none;
|
||||
height: calc(100vh - 57px);
|
||||
display: block;
|
||||
}
|
||||
</style>`,
|
||||
body: `<main class="rsocials-frame">
|
||||
<iframe class="rsocials-iframe" src="${src}" allow="clipboard-write"></iframe>
|
||||
</main>`,
|
||||
});
|
||||
}
|
||||
|
||||
// /rsocials/ → campaign list
|
||||
// ── Page route ──
|
||||
routes.get("/", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
return c.html(
|
||||
campaignFrame(`${RSOCIALS_PUBLIC}/campaigns`, space, `Campaigns | rSocials`)
|
||||
renderShell({
|
||||
title: `${space} — Socials | rSpace`,
|
||||
moduleId: "rsocials",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `
|
||||
<div class="rsocials-app" data-space="${space}">
|
||||
<div class="rsocials-header">
|
||||
<h2>Community Feed</h2>
|
||||
<p class="rsocials-subtitle">Social activity across your community</p>
|
||||
</div>
|
||||
<div id="rsocials-feed" class="rsocials-feed">
|
||||
<div class="rsocials-loading">Loading feed…</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module">
|
||||
const space = document.querySelector('.rsocials-app')?.dataset.space || 'demo';
|
||||
const feedEl = document.getElementById('rsocials-feed');
|
||||
|
||||
try {
|
||||
const res = await fetch('api/feed');
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.items?.length) {
|
||||
feedEl.innerHTML = '<p class="rsocials-empty">No posts yet. Share something with your community!</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
feedEl.innerHTML = data.items.map(item => {
|
||||
const time = new Date(item.timestamp);
|
||||
const ago = Math.round((Date.now() - time.getTime()) / 60000);
|
||||
const timeStr = ago < 60 ? ago + 'm ago' : Math.round(ago / 60) + 'h ago';
|
||||
const sourceTag = '<span class="rsocials-source">' + item.source + '</span>';
|
||||
|
||||
return '<article class="rsocials-item">' +
|
||||
'<div class="rsocials-item-header">' +
|
||||
'<strong>' + item.author + '</strong> ' + sourceTag +
|
||||
'<time>' + timeStr + '</time>' +
|
||||
'</div>' +
|
||||
'<p class="rsocials-item-content">' + item.content + '</p>' +
|
||||
(item.url ? '<a class="rsocials-item-link" href="' + item.url + '" target="_blank" rel="noopener">' + item.url + '</a>' : '') +
|
||||
'<div class="rsocials-item-actions">' +
|
||||
'<span>♥ ' + (item.likes || 0) + '</span>' +
|
||||
'<span>💬 ' + (item.replies || 0) + '</span>' +
|
||||
'</div>' +
|
||||
'</article>';
|
||||
}).join('');
|
||||
|
||||
if (data.demo) {
|
||||
feedEl.insertAdjacentHTML('beforeend',
|
||||
'<p class="rsocials-demo-notice">This is demo data. Connect ActivityPub or RSS feeds in your own space.</p>'
|
||||
);
|
||||
}
|
||||
} catch(e) {
|
||||
feedEl.innerHTML = '<p class="rsocials-empty">Failed to load feed.</p>';
|
||||
}
|
||||
</script>`,
|
||||
styles: `<style>
|
||||
.rsocials-app { max-width: 640px; margin: 0 auto; padding: 2rem 1rem; }
|
||||
.rsocials-header { margin-bottom: 1.5rem; }
|
||||
.rsocials-header h2 {
|
||||
font-size: 1.5rem; margin: 0 0 0.25rem;
|
||||
background: linear-gradient(135deg, #7dd3fc, #c4b5fd);
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
||||
}
|
||||
.rsocials-subtitle { color: #64748b; font-size: 0.85rem; margin: 0; }
|
||||
.rsocials-feed { display: flex; flex-direction: column; gap: 1px; }
|
||||
.rsocials-loading { color: #64748b; padding: 2rem 0; text-align: center; }
|
||||
.rsocials-empty { color: #64748b; padding: 2rem 0; text-align: center; }
|
||||
.rsocials-item {
|
||||
padding: 1rem; border-radius: 8px;
|
||||
background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.rsocials-item-header {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
font-size: 0.85rem; color: #94a3b8; margin-bottom: 0.5rem;
|
||||
}
|
||||
.rsocials-item-header strong { color: #e2e8f0; }
|
||||
.rsocials-item-header time { margin-left: auto; font-size: 0.75rem; }
|
||||
.rsocials-source {
|
||||
font-size: 0.65rem; padding: 1px 6px; border-radius: 4px;
|
||||
background: rgba(124,58,237,0.15); color: #c4b5fd;
|
||||
text-transform: uppercase; letter-spacing: 0.05em;
|
||||
}
|
||||
.rsocials-item-content { margin: 0 0 0.5rem; color: #cbd5e1; line-height: 1.5; font-size: 0.9rem; }
|
||||
.rsocials-item-link {
|
||||
display: block; font-size: 0.8rem; color: #7dd3fc;
|
||||
text-decoration: none; margin-bottom: 0.5rem; word-break: break-all;
|
||||
}
|
||||
.rsocials-item-link:hover { text-decoration: underline; }
|
||||
.rsocials-item-actions {
|
||||
display: flex; gap: 1rem; font-size: 0.8rem; color: #64748b;
|
||||
}
|
||||
.rsocials-demo-notice {
|
||||
text-align: center; font-size: 0.75rem; color: #475569;
|
||||
padding: 1rem 0; border-top: 1px solid rgba(255,255,255,0.05); margin-top: 0.5rem;
|
||||
}
|
||||
</style>`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// /rsocials/campaign → campaign list (alias)
|
||||
routes.get("/campaign", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
return c.html(
|
||||
campaignFrame(`${RSOCIALS_PUBLIC}/campaigns`, space, `Campaigns | rSocials`)
|
||||
);
|
||||
});
|
||||
|
||||
// /rsocials/campaign/:id → specific campaign editor
|
||||
routes.get("/campaign/:id", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const id = c.req.param("id");
|
||||
return c.html(
|
||||
campaignFrame(
|
||||
`${RSOCIALS_PUBLIC}/campaigns/${id}`,
|
||||
space,
|
||||
`Campaign Editor | rSocials`
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
type StatusCode = 200 | 201 | 204 | 400 | 401 | 403 | 404 | 409 | 500 | 502;
|
||||
|
||||
export const rsocialsModule: RSpaceModule = {
|
||||
export const socialsModule: RSpaceModule = {
|
||||
id: "rsocials",
|
||||
name: "rSocials",
|
||||
icon: "\uD83D\uDCE2",
|
||||
description: "Campaign strategy workflow builder",
|
||||
icon: "\u{1F4E2}",
|
||||
description: "Federated social feed aggregator for communities",
|
||||
routes,
|
||||
standaloneDomain: "rsocials.online",
|
||||
feeds: [
|
||||
{
|
||||
id: "social-feed",
|
||||
name: "Social Feed",
|
||||
kind: "data",
|
||||
description: "Community social timeline — posts, links, and activity from connected platforms",
|
||||
},
|
||||
],
|
||||
acceptsFeeds: ["data", "trust"],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ import { inboxModule } from "../modules/inbox/mod";
|
|||
import { dataModule } from "../modules/data/mod";
|
||||
import { splatModule } from "../modules/splat/mod";
|
||||
import { photosModule } from "../modules/photos/mod";
|
||||
import { rsocialsModule } from "../modules/rsocials/mod";
|
||||
import { socialsModule } from "../modules/rsocials/mod";
|
||||
import { spaces } from "./spaces";
|
||||
import { renderShell, renderModuleLanding } from "./shell";
|
||||
import { fetchLandingPage } from "./landing-proxy";
|
||||
|
|
@ -92,7 +92,7 @@ registerModule(inboxModule);
|
|||
registerModule(dataModule);
|
||||
registerModule(splatModule);
|
||||
registerModule(photosModule);
|
||||
registerModule(rsocialsModule);
|
||||
registerModule(socialsModule);
|
||||
|
||||
// ── Config ──
|
||||
const PORT = Number(process.env.PORT) || 3000;
|
||||
|
|
@ -928,17 +928,9 @@ const server = Bun.serve<WSData>({
|
|||
}
|
||||
}
|
||||
|
||||
// Root path → serve landing page (not the module app)
|
||||
// Root path → redirect to rspace.online/{moduleId} landing page
|
||||
if (url.pathname === "/") {
|
||||
const allModules = getAllModules();
|
||||
const mod = allModules.find((m) => m.id === standaloneModuleId);
|
||||
if (mod) {
|
||||
const html = renderModuleLanding({
|
||||
module: mod,
|
||||
modules: getModuleInfoList(),
|
||||
});
|
||||
return new Response(html, { headers: { "Content-Type": "text/html" } });
|
||||
}
|
||||
return Response.redirect(`https://rspace.online/${standaloneModuleId}`, 302);
|
||||
}
|
||||
|
||||
// Sub-paths: rewrite internally → /{space}/{moduleId}/...
|
||||
|
|
|
|||
Loading…
Reference in New Issue