rspace-online/modules/rsocials/mod.ts

1806 lines
72 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Socials module — federated social feed aggregator.
*
* Aggregates and displays social media activity across community members.
* 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";
import type { RSpaceModule } from "../../shared/module";
import { renderLanding } from "./landing";
import { MYCOFI_CAMPAIGN, PLATFORM_ICONS, PLATFORM_COLORS } from "./campaign-data";
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<string> {
await mkdir(THREADS_BASE, { recursive: true });
return THREADS_BASE;
}
async function loadThread(id: string): Promise<ThreadData | null> {
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<void> {
const dir = await ensureThreadsDir();
await writeFile(resolve(dir, `${data.id}.json`), JSON.stringify(data, null, 2));
}
async function deleteThreadFile(id: string): Promise<boolean> {
try {
const dir = await ensureThreadsDir();
await unlink(resolve(dir, `${id}.json`));
return true;
} catch {
return false;
}
}
async function listThreads(): Promise<Array<{ id: string; title: string; tweetCount: number; updatedAt: number }>> {
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" });
});
// ── 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",
],
});
});
// ── 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,
});
});
// ── API: Campaigns list ──
routes.get("/api/campaigns", (c) => {
const space = c.req.param("space") || "demo";
const campaign = MYCOFI_CAMPAIGN;
return c.json({
campaigns: [
{
id: campaign.id,
title: campaign.title,
description: campaign.description,
duration: campaign.duration,
platforms: campaign.platforms,
postCount: campaign.posts.length,
updated_at: "2026-02-21T09:00:00Z",
url: `/${space}/rsocials/campaign`,
},
],
});
});
// ── 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 });
});
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 rFlows river view! The enoughness score is such a powerful concept. \u{1F30A}",
timeAgo: "2 hours ago",
likes: 5,
replies: 2,
},
{
username: "@bob",
initial: "B",
color: "#f59e0b",
content: "Workshop recording is up on rTube: 'Introduction to Local-First Data'. Check it out!",
timeAgo: "5 hours ago",
likes: 8,
replies: 4,
},
{
username: "@carol",
initial: "C",
color: "#10b981",
content: "The cosmolocal print network now has 6 providers across 4 countries. Design global, manufacture local! \u{1F30D}",
timeAgo: "1 day ago",
likes: 12,
replies: 3,
},
{
username: "@diana",
initial: "D",
color: "#ec4899",
content: "Reading Elinor Ostrom's 'Governing the Commons' \u2014 so many parallels to what we're building with rSpace governance.",
timeAgo: "1 day ago",
likes: 7,
replies: 5,
},
{
username: "@eve",
initial: "E",
color: "#14b8a6",
content: "New community garden plot assignments are up on rChoices. Vote for your preferred plot by Friday!",
timeAgo: "2 days ago",
likes: 3,
replies: 1,
},
{
username: "@frank",
initial: "F",
color: "#8b5cf6",
content: "Mesh network node #42 is online! Coverage now extends to the community center. \u{1F4E1}",
timeAgo: "3 days ago",
likes: 15,
replies: 6,
},
];
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>`;
}
// ── Campaign page route ──
function renderCampaignPage(space: string): string {
const c = MYCOFI_CAMPAIGN;
const phases = [1, 2, 3];
const phaseIcons = ["📣", "🚀", "📡"];
const phaseHTML = phases.map((phaseNum, i) => {
const phasePosts = c.posts.filter((p) => p.phase === phaseNum);
const phaseInfo = c.phases[i];
const postsHTML = phasePosts.map((post) => {
const icon = PLATFORM_ICONS[post.platform] || post.platform;
const color = PLATFORM_COLORS[post.platform] || "#64748b";
const statusClass = post.status === "scheduled" ? "campaign-status--scheduled" : "campaign-status--draft";
const date = new Date(post.scheduledAt);
const dateStr = date.toLocaleDateString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
const contentPreview = escapeHtml(post.content.length > 180 ? post.content.substring(0, 180) + "..." : post.content);
const tags = post.hashtags.map((h) => `<span class="campaign-tag">#${escapeHtml(h)}</span>`).join(" ");
return `
<div class="campaign-post" data-platform="${escapeHtml(post.platform)}">
<div class="campaign-post__header">
<span class="campaign-post__platform" style="background:${color}">${icon}</span>
<div class="campaign-post__meta">
<strong>${escapeHtml(post.platform)}${escapeHtml(post.postType)}</strong>
<span class="campaign-post__date">${dateStr}</span>
</div>
<span class="campaign-status ${statusClass}">${escapeHtml(post.status)}</span>
</div>
<div class="campaign-post__step">Step ${post.stepNumber}</div>
<p class="campaign-post__content">${contentPreview.replace(/\n/g, "<br>")}</p>
<div class="campaign-post__tags">${tags}</div>
</div>`;
}).join("\n");
return `
<div class="campaign-phase">
<h3 class="campaign-phase__title">${phaseIcons[i]} Phase ${phaseNum}: ${escapeHtml(phaseInfo.label)} <span class="campaign-phase__days">${escapeHtml(phaseInfo.days)}</span></h3>
<div class="campaign-phase__posts">${postsHTML}</div>
</div>`;
}).join("\n");
return `
<div class="campaign-page">
<div class="campaign-page__header">
<span class="campaign-page__icon">🍄</span>
<div>
<h1 class="campaign-page__title">${escapeHtml(c.title)}</h1>
<p class="campaign-page__desc">${escapeHtml(c.description)}</p>
<div class="campaign-page__stats">
<span>📅 ${escapeHtml(c.duration)}</span>
<span>📱 ${c.platforms.join(", ")}</span>
<span>📝 ${c.posts.length} posts across ${c.phases.length} phases</span>
</div>
</div>
</div>
<div class="campaign-page__actions">
<a href="/${space}/rsocials/thread" class="campaign-action-btn campaign-action-btn--outline">Open Thread Builder</a>
<button class="campaign-action-btn campaign-action-btn--primary" id="import-md-btn">Import from Markdown</button>
</div>
${phaseHTML}
<div id="imported-posts"></div>
</div>
<div class="campaign-modal-overlay" id="import-modal" hidden>
<div class="campaign-modal">
<div class="campaign-modal__header">
<h3>Import from Markdown</h3>
<button class="campaign-modal__close" id="import-modal-close">&times;</button>
</div>
<textarea class="campaign-modal__textarea" id="import-md-textarea" placeholder="Paste tweets separated by ---\n\nFirst tweet\n---\nSecond tweet\n---\nThird tweet"></textarea>
<div class="campaign-modal__row">
<select class="campaign-modal__select" id="import-platform">
<option value="twitter">Twitter / X</option>
<option value="bluesky">Bluesky</option>
<option value="mastodon">Mastodon</option>
<option value="linkedin">LinkedIn</option>
</select>
<button class="campaign-action-btn campaign-action-btn--primary" id="import-parse-btn">Parse &amp; Add</button>
</div>
</div>
</div>
<script>
(function() {
const modal = document.getElementById('import-modal');
const openBtn = document.getElementById('import-md-btn');
const closeBtn = document.getElementById('import-modal-close');
const parseBtn = document.getElementById('import-parse-btn');
const mdInput = document.getElementById('import-md-textarea');
const platformSel = document.getElementById('import-platform');
const importedEl = document.getElementById('imported-posts');
openBtn.addEventListener('click', () => { modal.hidden = false; });
closeBtn.addEventListener('click', () => { modal.hidden = true; });
modal.addEventListener('click', (e) => { if (e.target === modal) modal.hidden = true; });
function esc(s) { return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
parseBtn.addEventListener('click', () => {
const raw = mdInput.value;
const tweets = raw.split(/\\n---\\n/).map(t => t.trim()).filter(Boolean);
if (!tweets.length) return;
const platform = platformSel.value;
const total = tweets.length;
let html = '<div class="campaign-phase"><h3 class="campaign-phase__title">📥 Imported Posts (' + total + ')</h3>';
html += '<div class="campaign-phase__posts">';
tweets.forEach((text, i) => {
const preview = text.length > 180 ? esc(text.substring(0, 180)) + '...' : esc(text);
html += '<div class="campaign-post">' +
'<div class="campaign-post__header">' +
'<span class="campaign-post__platform" style="background:#6366f1">' + esc(platform.charAt(0).toUpperCase()) + '</span>' +
'<div class="campaign-post__meta"><strong>' + esc(platform) + '</strong></div>' +
'<span class="campaign-status campaign-status--draft">imported</span>' +
'</div>' +
'<div class="campaign-post__step">Tweet ' + (i + 1) + '/' + total + '</div>' +
'<p class="campaign-post__content">' + preview.replace(/\\n/g, '<br>') + '</p>' +
'</div>';
});
html += '</div></div>';
importedEl.innerHTML = html;
modal.hidden = true;
});
})();
</script>`;
}
const CAMPAIGN_CSS = `
.campaign-page { max-width: 900px; margin: 0 auto; padding: 2rem 1rem; }
.campaign-page__header { display: flex; align-items: flex-start; gap: 1rem; margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid #334155; }
.campaign-page__icon { font-size: 3rem; }
.campaign-page__title { margin: 0; font-size: 1.5rem; color: #f1f5f9; }
.campaign-page__desc { margin: 0.25rem 0 0.5rem; color: #94a3b8; font-size: 0.9rem; line-height: 1.5; }
.campaign-page__stats { display: flex; flex-wrap: wrap; gap: 1rem; font-size: 0.8rem; color: #64748b; }
.campaign-phase { margin-bottom: 2rem; }
.campaign-phase__title { font-size: 1.15rem; color: #e2e8f0; margin: 0 0 1rem; display: flex; align-items: center; gap: 0.5rem; }
.campaign-phase__days { font-size: 0.8rem; color: #64748b; font-weight: 400; }
.campaign-phase__posts { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.75rem; }
.campaign-post {
background: #1e293b; border: 1px solid #334155; border-radius: 0.75rem; padding: 1rem;
transition: border-color 0.15s;
}
.campaign-post:hover { border-color: #6366f1; }
.campaign-post__header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
.campaign-post__platform {
width: 28px; height: 28px; border-radius: 6px; display: flex; align-items: center; justify-content: center;
color: white; font-size: 0.75rem; font-weight: 700; flex-shrink: 0;
}
.campaign-post__meta { flex: 1; min-width: 0; }
.campaign-post__meta strong { display: block; font-size: 0.8rem; color: #e2e8f0; text-transform: capitalize; }
.campaign-post__date { font-size: 0.7rem; color: #64748b; }
.campaign-post__step { font-size: 0.65rem; color: #6366f1; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; }
.campaign-status { font-size: 0.6rem; padding: 2px 6px; border-radius: 4px; text-transform: uppercase; font-weight: 600; letter-spacing: 0.05em; white-space: nowrap; }
.campaign-status--scheduled { background: rgba(16,185,129,0.15); color: #34d399; }
.campaign-status--draft { background: rgba(251,191,36,0.15); color: #fbbf24; }
.campaign-post__content { font-size: 0.8rem; color: #94a3b8; line-height: 1.5; margin: 0 0 0.5rem; }
.campaign-post__tags { display: flex; flex-wrap: wrap; gap: 0.25rem; }
.campaign-tag { font-size: 0.65rem; color: #7dd3fc; }
.campaign-page__actions { display: flex; gap: 0.75rem; margin-bottom: 1.5rem; }
.campaign-action-btn {
padding: 0.5rem 1rem; border-radius: 8px; font-size: 0.85rem; font-weight: 600;
cursor: pointer; transition: all 0.15s; text-decoration: none; display: inline-flex; align-items: center;
}
.campaign-action-btn--primary { background: #6366f1; color: white; border: none; }
.campaign-action-btn--primary:hover { background: #818cf8; }
.campaign-action-btn--outline { background: transparent; color: #94a3b8; border: 1px solid #334155; }
.campaign-action-btn--outline:hover { border-color: #6366f1; color: #c4b5fd; }
.campaign-modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex;
align-items: center; justify-content: center; z-index: 1000;
}
.campaign-modal-overlay[hidden] { display: none; }
.campaign-modal {
background: #1e293b; border: 1px solid #334155; border-radius: 0.75rem;
padding: 1.5rem; width: 90%; max-width: 540px; display: flex; flex-direction: column; gap: 1rem;
}
.campaign-modal__header { display: flex; align-items: center; justify-content: space-between; }
.campaign-modal__header h3 { margin: 0; font-size: 1.1rem; color: #f1f5f9; }
.campaign-modal__close {
background: none; border: none; color: #64748b; font-size: 1.5rem; cursor: pointer;
line-height: 1; padding: 0;
}
.campaign-modal__close:hover { color: #e2e8f0; }
.campaign-modal__textarea {
width: 100%; min-height: 200px; background: #0f172a; color: #e2e8f0; border: 1px solid #334155;
border-radius: 8px; padding: 0.75rem; font-family: inherit; font-size: 0.85rem; resize: vertical;
line-height: 1.5; box-sizing: border-box;
}
.campaign-modal__textarea:focus { outline: none; border-color: #6366f1; }
.campaign-modal__textarea::placeholder { color: #475569; }
.campaign-modal__row { display: flex; gap: 0.75rem; align-items: center; }
.campaign-modal__select {
flex: 1; background: #0f172a; color: #e2e8f0; border: 1px solid #334155;
border-radius: 8px; padding: 0.5rem 0.75rem; font-size: 0.85rem;
}
.campaign-modal__select:focus { outline: none; border-color: #6366f1; }
`;
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: renderCampaignPage(space),
styles: `<style>${CAMPAIGN_CSS}</style>`,
}));
});
// ── 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; 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;
border-radius: 0.75rem; padding: 1rem; font-family: inherit; font-size: 0.9rem; resize: vertical;
line-height: 1.6; box-sizing: border-box;
}
.thread-compose__textarea:focus { outline: none; border-color: #6366f1; }
.thread-compose__textarea::placeholder { color: #475569; }
.thread-compose__fields { display: flex; gap: 0.75rem; }
.thread-compose__input {
flex: 1; 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__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 {
position: relative; background: #1e293b; border: 1px solid #334155; border-radius: 0.75rem;
padding: 1rem; margin-bottom: 0;
}
.tweet-card + .tweet-card { border-top-left-radius: 0; border-top-right-radius: 0; margin-top: -1px; }
.tweet-card:has(+ .tweet-card) { border-bottom-left-radius: 0; border-bottom-right-radius: 0; }
.tweet-card__connector {
position: absolute; left: 29px; top: -1px; width: 2px; height: 1rem;
background: #334155; z-index: 1;
}
.tweet-card__header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
.tweet-card__avatar {
width: 40px; height: 40px; border-radius: 50%; background: #6366f1;
display: flex; align-items: center; justify-content: center; color: white;
font-weight: 700; font-size: 1rem; flex-shrink: 0;
}
.tweet-card__name { font-weight: 700; color: #f1f5f9; font-size: 0.9rem; }
.tweet-card__handle { color: #64748b; font-size: 0.85rem; }
.tweet-card__dot { color: #64748b; font-size: 0.85rem; }
.tweet-card__time { color: #64748b; font-size: 0.85rem; }
.tweet-card__content { color: #e2e8f0; font-size: 0.95rem; line-height: 1.6; margin: 0 0 0.75rem; white-space: pre-wrap; word-break: break-word; }
.tweet-card__footer { display: flex; align-items: center; justify-content: space-between; }
.tweet-card__actions { display: flex; gap: 1.25rem; }
.tweet-card__action { display: flex; align-items: center; gap: 0.3rem; color: #64748b; font-size: 0.8rem; cursor: default; }
.tweet-card__action svg { width: 16px; height: 16px; }
.tweet-card__meta { display: flex; align-items: center; gap: 0.75rem; font-size: 0.75rem; color: #64748b; }
.tweet-card__chars { font-variant-numeric: tabular-nums; }
.tweet-card__chars--over { color: #ef4444; font-weight: 600; }
.tweet-card__thread-num { color: #6366f1; font-weight: 600; }
@media (max-width: 700px) {
.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 {
const dataScript = threadData
? `<script>window.__THREAD_DATA__ = ${JSON.stringify(threadData).replace(/</g, "\\u003c")};</script>`
: "";
const basePath = `/${space}/rsocials/`;
return `
${dataScript}
<script>window.__BASE_PATH__ = ${JSON.stringify(basePath)};</script>
<div class="thread-page">
<div class="thread-page__header">
<h1>Thread Builder</h1>
<div class="thread-page__actions">
<button class="thread-btn thread-btn--primary" id="thread-save">Save Draft</button>
<button class="thread-btn thread-btn--success" id="thread-share">Share</button>
<button class="thread-btn thread-btn--outline" id="thread-copy">Copy Thread</button>
<div class="thread-export-dropdown">
<button class="thread-btn thread-btn--outline" id="thread-export-btn">Export &#9662;</button>
<div class="thread-export-menu" id="thread-export-menu" hidden>
<button data-platform="twitter">𝕏 Twitter (280)</button>
<button data-platform="bluesky">🦋 Bluesky (300)</button>
<button data-platform="mastodon">🐘 Mastodon (500)</button>
<button data-platform="linkedin">💼 LinkedIn</button>
<button data-platform="plain">📄 Plain Text</button>
</div>
</div>
</div>
</div>
<div class="thread-drafts">
<button class="thread-btn thread-btn--outline thread-drafts__toggle" id="toggle-drafts">Saved Drafts &#9662;</button>
<div class="thread-drafts__list" id="drafts-list" hidden></div>
</div>
<div id="share-link-area"></div>
<div class="thread-compose">
<textarea class="thread-compose__textarea" id="thread-input" placeholder="Write your tweets here, separated by ---\n\nExample:\nFirst tweet goes here\n---\nSecond tweet\n---\nThird tweet"></textarea>
<div class="thread-compose__fields">
<input class="thread-compose__input" id="thread-name" placeholder="Display name" value="Your Name">
<input class="thread-compose__input" id="thread-handle" placeholder="@handle" value="@yourhandle">
</div>
<input class="thread-compose__title" id="thread-title" placeholder="Thread title (defaults to first tweet)">
<div class="thread-image-section">
<input type="file" id="upload-image-input" accept="image/png,image/jpeg,image/webp,image/gif" hidden>
<button class="thread-btn thread-btn--outline" id="upload-image-btn">Upload Image</button>
<button class="thread-btn thread-btn--outline" id="gen-image-btn">Generate with AI</button>
<div class="thread-image-preview" id="thread-image-preview" hidden>
<img id="thread-image-thumb" alt="Preview">
</div>
</div>
</div>
<div class="thread-preview" id="thread-preview">
<div class="thread-preview__empty">Your tweet thread preview will appear here</div>
</div>
</div>
<script type="module">
const base = window.__BASE_PATH__ || './';
let currentThreadId = null;
let autoSaveTimer = null;
const textarea = document.getElementById('thread-input');
const preview = document.getElementById('thread-preview');
const nameInput = document.getElementById('thread-name');
const handleInput = document.getElementById('thread-handle');
const titleInput = document.getElementById('thread-title');
const copyBtn = document.getElementById('thread-copy');
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');
const imageThumb = document.getElementById('thread-image-thumb');
const shareLinkArea = document.getElementById('share-link-area');
const svgReply = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>';
const svgRetweet = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M17 1l4 4-4 4"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><path d="M7 23l-4-4 4-4"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>';
const svgHeart = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><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>';
const svgShare = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg>';
function esc(s) { return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function renderPreview() {
const raw = textarea.value;
const tweets = raw.split(/\\n---\\n/).map(t => t.trim()).filter(Boolean);
const name = nameInput.value || 'Your Name';
const handle = handleInput.value || '@yourhandle';
const initial = name.charAt(0).toUpperCase();
const total = tweets.length;
if (!total) {
preview.innerHTML = '<div class="thread-preview__empty">Your tweet thread preview will appear here</div>';
return;
}
preview.innerHTML = tweets.map((text, i) => {
const len = text.length;
const overClass = len > 280 ? ' tweet-card__chars--over' : '';
const connector = i > 0 ? '<div class="tweet-card__connector"></div>' : '';
return '<div class="tweet-card">' +
connector +
'<div class="tweet-card__header">' +
'<div class="tweet-card__avatar">' + esc(initial) + '</div>' +
'<span class="tweet-card__name">' + esc(name) + '</span>' +
'<span class="tweet-card__handle">' + esc(handle) + '</span>' +
'<span class="tweet-card__dot">&#183;</span>' +
'<span class="tweet-card__time">now</span>' +
'</div>' +
'<p class="tweet-card__content">' + esc(text) + '</p>' +
'<div class="tweet-card__footer">' +
'<div class="tweet-card__actions">' +
'<span class="tweet-card__action">' + svgReply + '</span>' +
'<span class="tweet-card__action">' + svgRetweet + '</span>' +
'<span class="tweet-card__action">' + svgHeart + '</span>' +
'<span class="tweet-card__action">' + svgShare + '</span>' +
'</div>' +
'<div class="tweet-card__meta">' +
'<span class="tweet-card__chars' + overClass + '">' + len + '/280</span>' +
'<span class="tweet-card__thread-num">' + (i + 1) + '/' + total + '</span>' +
'</div>' +
'</div>' +
'</div>';
}).join('');
}
// ── Draft management ──
async function saveDraft() {
const tweets = textarea.value.split(/\\n---\\n/).map(t => t.trim()).filter(Boolean);
if (!tweets.length) return;
const body = {
name: nameInput.value || 'Your Name',
handle: handleInput.value || '@yourhandle',
title: titleInput.value || tweets[0].substring(0, 60),
tweets,
};
try {
let res;
if (currentThreadId) {
res = await fetch(base + 'api/threads/' + currentThreadId, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
} else {
res = await fetch(base + 'api/threads', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
const data = await res.json();
if (data.id) {
currentThreadId = data.id;
history.replaceState(null, '', base + 'thread/' + data.id + '/edit');
}
saveBtn.textContent = 'Saved!';
setTimeout(() => { saveBtn.textContent = 'Save Draft'; }, 2000);
loadDraftList();
} catch (e) {
saveBtn.textContent = 'Error';
setTimeout(() => { saveBtn.textContent = 'Save Draft'; }, 2000);
}
}
async function loadDraft(id) {
try {
const res = await fetch(base + 'api/threads/' + id);
const data = await res.json();
if (data.error) return;
currentThreadId = data.id;
nameInput.value = data.name || '';
handleInput.value = data.handle || '';
titleInput.value = data.title || '';
textarea.value = data.tweets.join('\\n---\\n');
if (data.imageUrl) {
imageThumb.src = data.imageUrl;
imagePreview.hidden = false;
genImageBtn.textContent = 'Replace with AI';
uploadImageBtn.textContent = 'Replace Image';
} else {
imagePreview.hidden = true;
genImageBtn.textContent = 'Generate with AI';
uploadImageBtn.textContent = 'Upload Image';
}
history.replaceState(null, '', base + 'thread/' + data.id + '/edit');
renderPreview();
loadDraftList();
} catch (e) {
console.error('Failed to load draft:', e);
}
}
async function deleteDraft(id) {
if (!confirm('Delete this draft?')) return;
try {
await fetch(base + 'api/threads/' + id, { method: 'DELETE' });
if (currentThreadId === id) {
currentThreadId = null;
history.replaceState(null, '', base + 'thread');
imagePreview.hidden = true;
genImageBtn.textContent = 'Generate with AI';
uploadImageBtn.textContent = 'Upload Image';
shareLinkArea.innerHTML = '';
}
loadDraftList();
} catch (e) {
console.error('Failed to delete draft:', e);
}
}
async function loadDraftList() {
try {
const res = await fetch(base + 'api/threads');
const data = await res.json();
if (!data.threads?.length) {
draftsList.innerHTML = '<div class="thread-drafts__empty">No saved drafts</div>';
return;
}
draftsList.innerHTML = data.threads.map(t => {
const date = new Date(t.updatedAt);
const dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
const active = t.id === currentThreadId ? ' thread-draft-item--active' : '';
return '<div class="thread-draft-item' + active + '">' +
'<div class="thread-draft-item__info" data-load-id="' + esc(t.id) + '">' +
'<strong>' + esc(t.title) + '</strong>' +
'<span>' + t.tweetCount + ' tweets &#183; ' + dateStr + '</span>' +
'</div>' +
'<button class="thread-draft-item__delete" data-delete-id="' + esc(t.id) + '">&#215;</button>' +
'</div>';
}).join('');
// Attach event listeners
draftsList.querySelectorAll('[data-load-id]').forEach(el => {
el.addEventListener('click', () => loadDraft(el.dataset.loadId));
});
draftsList.querySelectorAll('[data-delete-id]').forEach(el => {
el.addEventListener('click', (e) => { e.stopPropagation(); deleteDraft(el.dataset.deleteId); });
});
} catch (e) {
draftsList.innerHTML = '<div class="thread-drafts__empty">Failed to load drafts</div>';
}
}
async function shareThread() {
shareBtn.textContent = 'Saving...';
shareBtn.disabled = true;
try {
await saveDraft();
if (!currentThreadId) { shareBtn.textContent = 'Share'; shareBtn.disabled = false; return; }
if (imagePreview.hidden) {
shareBtn.textContent = 'Generating image...';
await generateImage();
}
const url = window.location.origin + base + 'thread/' + currentThreadId;
try {
await navigator.clipboard.writeText(url);
shareBtn.textContent = 'Link Copied!';
} catch {
shareBtn.textContent = 'Shared!';
}
shareLinkArea.innerHTML = '<div class="thread-share-link">' +
'<code>' + esc(url) + '</code>' +
'<button id="copy-share-link">Copy</button>' +
'</div>';
document.getElementById('copy-share-link')?.addEventListener('click', () => {
navigator.clipboard.writeText(url);
});
} catch (e) {
shareBtn.textContent = 'Error';
}
setTimeout(() => { shareBtn.textContent = 'Share'; shareBtn.disabled = false; }, 3000);
}
async function generateImage() {
if (!currentThreadId) {
await saveDraft();
if (!currentThreadId) return;
}
genImageBtn.textContent = 'Generating...';
genImageBtn.disabled = true;
try {
const res = await fetch(base + 'api/threads/' + currentThreadId + '/image', { method: 'POST' });
const data = await res.json();
if (data.imageUrl) {
imageThumb.src = data.imageUrl;
imagePreview.hidden = false;
genImageBtn.textContent = 'Replace with AI';
uploadImageBtn.textContent = 'Replace Image';
} else {
genImageBtn.textContent = 'Generation Failed';
setTimeout(() => { genImageBtn.textContent = imagePreview.hidden ? 'Generate with AI' : 'Replace with AI'; }, 2000);
}
} catch (e) {
genImageBtn.textContent = 'Generation Failed';
setTimeout(() => { genImageBtn.textContent = imagePreview.hidden ? 'Generate with AI' : 'Replace with AI'; }, 2000);
} finally {
genImageBtn.disabled = false;
}
}
// Auto-save on blur if thread has been saved once
function scheduleAutoSave() {
if (!currentThreadId) return;
clearTimeout(autoSaveTimer);
autoSaveTimer = setTimeout(() => saveDraft(), 1000);
}
// ── Event listeners ──
textarea.addEventListener('input', renderPreview);
nameInput.addEventListener('input', renderPreview);
handleInput.addEventListener('input', renderPreview);
textarea.addEventListener('blur', scheduleAutoSave);
nameInput.addEventListener('blur', scheduleAutoSave);
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;
toggleDraftsBtn.innerHTML = draftsList.hidden ? 'Saved Drafts &#9662;' : 'Saved Drafts &#9652;';
});
copyBtn.addEventListener('click', async () => {
const tweets = textarea.value.split(/\\n---\\n/).map(t => t.trim()).filter(Boolean);
if (!tweets.length) return;
const total = tweets.length;
const text = tweets.map((t, i) => (i + 1) + '/' + total + '\\n' + t).join('\\n\\n');
try {
await navigator.clipboard.writeText(text);
copyBtn.textContent = 'Copied!';
setTimeout(() => { copyBtn.textContent = 'Copy Thread'; }, 2000);
} catch(e) {
copyBtn.textContent = 'Failed';
setTimeout(() => { copyBtn.textContent = 'Copy Thread'; }, 2000);
}
});
// ── 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__;
currentThreadId = data.id;
nameInput.value = data.name || '';
handleInput.value = data.handle || '';
titleInput.value = data.title || '';
textarea.value = data.tweets.join('\\n---\\n');
if (data.imageUrl) {
imageThumb.src = data.imageUrl;
imagePreview.hidden = false;
genImageBtn.textContent = 'Replace with AI';
uploadImageBtn.textContent = 'Replace Image';
}
renderPreview();
}
loadDraftList();
</script>`;
}
// ── 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 ? '<div class="tweet-card__connector"></div>' : "";
return `<div class="tweet-card">
${connector}
<div class="tweet-card__header">
<div class="tweet-card__avatar">${escapeHtml(initial)}</div>
<span class="tweet-card__name">${name}</span>
<span class="tweet-card__handle">${handle}</span>
<span class="tweet-card__dot">&#183;</span>
<span class="tweet-card__time">${escapeHtml(dateStr)}</span>
</div>
<p class="tweet-card__content">${escapeHtml(text)}</p>
<div class="tweet-card__footer">
<div class="tweet-card__actions">
<span class="tweet-card__action"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg></span>
<span class="tweet-card__action"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M17 1l4 4-4 4"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><path d="M7 23l-4-4 4-4"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg></span>
<span class="tweet-card__action"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><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></span>
<span class="tweet-card__action"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg></span>
</div>
<div class="tweet-card__meta">
<span class="tweet-card__chars${len > 280 ? " tweet-card__chars--over" : ""}">${len}/280</span>
<span class="tweet-card__thread-num">${i + 1}/${total}</span>
</div>
</div>
</div>`;
}).join("\n");
const imageHTML = thread.imageUrl
? `<div class="thread-ro__image"><img src="${escapeHtml(thread.imageUrl)}" alt="Thread preview"></div>`
: "";
return `
<div class="thread-ro">
<div class="thread-ro__header">
<div class="thread-ro__author">
<div class="tweet-card__avatar" style="width:48px;height:48px;font-size:1.2rem">${escapeHtml(initial)}</div>
<div>
<div class="thread-ro__name">${name}</div>
<div class="thread-ro__handle">${handle}</div>
</div>
</div>
<div class="thread-ro__meta">
<span>${total} tweet${total === 1 ? "" : "s"}</span>
<span>&#183;</span>
<span>${escapeHtml(dateStr)}</span>
</div>
</div>
${thread.title ? `<h1 class="thread-ro__title">${escapeHtml(thread.title)}</h1>` : ""}
${imageHTML}
<div class="thread-preview thread-ro__cards">
${tweetCards}
</div>
<div class="thread-ro__actions">
<a href="/${escapeHtml(space)}/rsocials/thread/${escapeHtml(thread.id)}/edit" class="thread-btn thread-btn--primary">Edit Thread</a>
<button class="thread-btn thread-btn--outline" id="ro-copy-thread">Copy Thread</button>
<button class="thread-btn thread-btn--outline" id="ro-copy-link">Copy Link</button>
<div class="thread-export-dropdown">
<button class="thread-btn thread-btn--outline" id="ro-export-btn">Export &#9662;</button>
<div class="thread-export-menu" id="ro-export-menu" hidden>
<button data-platform="twitter">𝕏 Twitter (280)</button>
<button data-platform="bluesky">🦋 Bluesky (300)</button>
<button data-platform="mastodon">🐘 Mastodon (500)</button>
<button data-platform="linkedin">💼 LinkedIn</button>
<button data-platform="plain">📄 Plain Text</button>
</div>
</div>
</div>
<div class="thread-ro__cta">
<a href="/${escapeHtml(space)}/rsocials/thread" class="thread-btn thread-btn--success">Create Your Own Thread</a>
<a href="/${escapeHtml(space)}/rsocials/threads" class="thread-btn thread-btn--outline">Browse All Threads</a>
</div>
</div>
<div class="thread-export-toast" id="export-toast" hidden></div>
<script>
(function() {
const threadData = ${JSON.stringify({ tweets: thread.tweets, name: thread.name, handle: thread.handle, title: thread.title }).replace(/</g, "\\u003c")};
const url = window.location.href;
function showToast(msg) {
const toast = document.getElementById('export-toast');
toast.textContent = msg;
toast.hidden = false;
setTimeout(() => { toast.hidden = true; }, 2500);
}
document.getElementById('ro-copy-thread')?.addEventListener('click', async () => {
const text = threadData.tweets.map((t, i) => (i + 1) + '/' + threadData.tweets.length + '\\n' + t).join('\\n\\n');
try { await navigator.clipboard.writeText(text); showToast('Thread copied!'); }
catch { showToast('Failed to copy'); }
});
document.getElementById('ro-copy-link')?.addEventListener('click', async () => {
try { await navigator.clipboard.writeText(url); showToast('Link copied!'); }
catch { showToast('Failed to copy'); }
});
// Export dropdown
const exportBtn = document.getElementById('ro-export-btn');
const exportMenu = document.getElementById('ro-export-menu');
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 = threadData.tweets;
const total = tweets.length;
const name = threadData.name || 'Thread';
const handle = threadData.handle || '';
let warnings = [];
if (platform === 'linkedin') {
let text = '';
if (threadData.title) text += threadData.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 { text, warnings } = formatForPlatform(platform);
try {
await navigator.clipboard.writeText(text);
if (warnings.length) {
showToast('Copied with warnings: ' + warnings.join('; '));
} else {
showToast('Copied for ' + platform + '!');
}
} catch { showToast('Failed to copy'); }
exportMenu.hidden = true;
});
});
})();
</script>`;
}
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");
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 = `
<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}">`;
}
return c.html(renderShell({
title: `${thread.title || "Thread"} — rSocials | rSpace`,
moduleId: "rsocials",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: renderThreadReadOnly(space, thread),
styles: `<style>${THREAD_CSS}${THREAD_RO_CSS}</style>`,
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: `<style>${THREAD_CSS}</style>`,
}));
});
// ── Thread builder (new) ──
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: renderThreadBuilderPage(space),
styles: `<style>${THREAD_CSS}</style>`,
}));
});
// ── 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<string> {
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 `
<div class="threads-gallery">
<div class="threads-gallery__header">
<h1>Threads</h1>
<a href="/${escapeHtml(space)}/rsocials/thread" class="thread-btn thread-btn--primary">New Thread</a>
</div>
<div class="threads-gallery__empty">
<p>No threads yet. Create your first thread!</p>
<a href="/${escapeHtml(space)}/rsocials/thread" class="thread-btn thread-btn--success" style="margin-top:1rem;display:inline-flex">Create Thread</a>
</div>
</div>`;
}
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
? `<div class="thread-card__image"><img src="${escapeHtml(t.imageUrl)}" alt="" loading="lazy"></div>`
: "";
return `<a href="/${escapeHtml(space)}/rsocials/thread/${escapeHtml(t.id)}" class="thread-card">
${imageTag}
<h3 class="thread-card__title">${escapeHtml(t.title || "Untitled Thread")}</h3>
<p class="thread-card__preview">${preview}</p>
<div class="thread-card__meta">
<div class="thread-card__author">
<div class="thread-card__avatar-sm">${escapeHtml(initial)}</div>
<span>${escapeHtml(t.handle || t.name || "Anonymous")}</span>
</div>
<span>${t.tweets.length} tweet${t.tweets.length === 1 ? "" : "s"}</span>
<span>${dateStr}</span>
</div>
</a>`;
}).join("\n");
return `
<div class="threads-gallery">
<div class="threads-gallery__header">
<h1>Threads</h1>
<a href="/${escapeHtml(space)}/rsocials/thread" class="thread-btn thread-btn--primary">New Thread</a>
</div>
<div class="threads-gallery__grid">
${cards}
</div>
</div>`;
}
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: `<style>${THREAD_CSS}${THREADS_LIST_CSS}</style>`,
}));
});
// ── Campaigns redirect (plural → singular) ──
routes.get("/campaigns", (c) => {
const space = c.req.param("space") || "demo";
return c.redirect(`/${space}/rsocials/campaign`);
});
// ── 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()
: `
<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>`;
return c.html(
renderShell({
title: `${space} — Socials | rSpace`,
moduleId: "rsocials",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body,
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; display: flex; align-items: center; gap: 0.75rem;
background: linear-gradient(135deg, #7dd3fc, #c4b5fd);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
.rsocials-demo-badge {
font-size: 0.6rem; font-weight: 700; letter-spacing: 0.08em;
background: #6366f1; color: white;
-webkit-text-fill-color: white;
padding: 2px 8px; border-radius: 4px;
text-transform: uppercase; line-height: 1.6;
}
.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: #1e293b; border: 1px solid rgba(255,255,255,0.06);
margin-bottom: 0.5rem;
transition: border-color 0.2s;
}
.rsocials-item:hover { border-color: rgba(99,102,241,0.3); }
.rsocials-item-header {
display: flex; align-items: center; gap: 0.75rem;
font-size: 0.85rem; color: #94a3b8; margin-bottom: 0.5rem;
}
.rsocials-avatar {
width: 36px; height: 36px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
color: white; font-weight: 700; font-size: 0.85rem;
flex-shrink: 0;
}
.rsocials-meta {
display: flex; flex-direction: column; gap: 1px;
}
.rsocials-meta strong { color: #e2e8f0; font-size: 0.9rem; }
.rsocials-meta time { font-size: 0.75rem; color: #64748b; }
.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.75rem; color: #cbd5e1; line-height: 1.6; 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-action {
display: flex; align-items: center; gap: 0.35rem;
cursor: default;
}
.rsocials-action svg { opacity: 0.7; }
.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>`,
}),
);
});
export const socialsModule: RSpaceModule = {
id: "rsocials",
name: "rSocials",
icon: "📢",
description: "Federated social feed aggregator for communities",
scoping: { defaultScope: 'global', userConfigurable: true },
routes,
standaloneDomain: "rsocials.online",
landingPage: renderLanding,
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" },
],
};