feat: refactor rSocials from monolith to full rApp

Decompose the 2,116-line mod.ts into a canonical rApp matching the
rFlows/rBooks pattern with Automerge sync, web components, and
extracted CSS.

New files:
- schemas.ts: SocialsDoc, ThreadData, Campaign types + Automerge schema
- local-first-client.ts: browser-side sync client
- lib/types.ts: shared types, DEMO_FEED, PLATFORM_LIMITS
- lib/image-gen.ts: server-only fal.ai + file upload helpers
- components/folk-thread-builder.ts: compose/preview/readonly component
- components/folk-thread-gallery.ts: thread listing grid component
- components/folk-campaign-manager.ts: campaign viewer with import modal
- components/socials.css: all extracted CSS (~550 lines)

mod.ts slimmed to ~616 lines: ensureDoc, image APIs, page routes
injecting <folk-*> web components, file→Automerge migration, seed
template, and module export. Thread/campaign CRUD moved from REST
to Automerge local-first sync.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-04 20:42:18 -08:00
parent fff0fd3150
commit 91d414fc88
10 changed files with 2865 additions and 1884 deletions

View File

@ -0,0 +1,314 @@
/**
* <folk-campaign-manager> Campaign viewer/editor with import modal.
*
* Subscribes to Automerge doc for campaign data. Falls back to MYCOFI_CAMPAIGN
* demo data when no campaigns exist or space=demo.
*/
import { socialsSchema, socialsDocId } from '../schemas';
import type { SocialsDoc, Campaign, CampaignPost } from '../schemas';
import type { DocumentId } from '../../../shared/local-first/document';
import { MYCOFI_CAMPAIGN, PLATFORM_ICONS, PLATFORM_COLORS } from '../campaign-data';
export class FolkCampaignManager extends HTMLElement {
private _space = 'demo';
private _campaigns: Campaign[] = [];
private _offlineUnsub: (() => void) | null = null;
static get observedAttributes() { return ['space']; }
connectedCallback() {
this.attachShadow({ mode: 'open' });
this._space = this.getAttribute('space') || 'demo';
// Start with demo campaign
this._campaigns = [{ ...MYCOFI_CAMPAIGN, createdAt: Date.now(), updatedAt: Date.now() }];
this.render();
if (this._space !== 'demo') {
this.subscribeOffline();
}
}
disconnectedCallback() {
this._offlineUnsub?.();
this._offlineUnsub = null;
}
attributeChangedCallback(name: string, _old: string, val: string) {
if (name === 'space') this._space = val;
}
private async subscribeOffline() {
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime?.isInitialized) return;
try {
const docId = socialsDocId(this._space) as DocumentId;
const doc = await runtime.subscribe(docId, socialsSchema);
this.renderFromDoc(doc);
this._offlineUnsub = runtime.onChange(docId, (updated: any) => {
this.renderFromDoc(updated);
});
} catch {
// Runtime unavailable — use demo data
}
}
private renderFromDoc(doc: SocialsDoc) {
if (!doc?.campaigns || Object.keys(doc.campaigns).length === 0) return;
this._campaigns = Object.values(doc.campaigns).sort((a, b) => b.updatedAt - a.updatedAt);
this.render();
}
private saveCampaignToDoc(campaign: Campaign) {
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime?.isInitialized) return;
const docId = socialsDocId(this._space) as DocumentId;
runtime.change(docId, `Save campaign ${campaign.title}`, (d: SocialsDoc) => {
if (!d.campaigns) d.campaigns = {} as any;
campaign.updatedAt = Date.now();
d.campaigns[campaign.id] = campaign;
});
}
private esc(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
private renderCampaign(c: Campaign): string {
const phases = [1, 2, 3];
const phaseIcons = ['📣', '🚀', '📡'];
const phaseHTML = phases.map((phaseNum, i) => {
const phasePosts = c.posts.filter(p => p.phase === phaseNum);
if (!phasePosts.length && !c.phases[i]) return '';
const phaseInfo = c.phases[i] || { label: `Phase ${phaseNum}`, days: '' };
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' ? 'status--scheduled' : '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 = post.content.length > 180 ? this.esc(post.content.substring(0, 180)) + '...' : this.esc(post.content);
const tags = post.hashtags.map(h => `<span class="tag">#${this.esc(h)}</span>`).join(' ');
return `
<div class="post" data-platform="${this.esc(post.platform)}">
<div class="post__header">
<span class="post__platform" style="background:${color}">${icon}</span>
<div class="post__meta">
<strong>${this.esc(post.platform)} ${this.esc(post.postType)}</strong>
<span class="post__date">${dateStr}</span>
</div>
<span class="status ${statusClass}">${this.esc(post.status)}</span>
</div>
<div class="post__step">Step ${post.stepNumber}</div>
<p class="post__content">${contentPreview.replace(/\n/g, '<br>')}</p>
<div class="post__tags">${tags}</div>
</div>`;
}).join('');
return `
<div class="phase">
<h3 class="phase__title">${phaseIcons[i] || '📋'} Phase ${phaseNum}: ${this.esc(phaseInfo.label)} <span class="phase__days">${this.esc(phaseInfo.days)}</span></h3>
<div class="phase__posts">${postsHTML}</div>
</div>`;
}).join('');
return `
<div class="campaign-header">
<span class="campaign-icon">🍄</span>
<div>
<h1 class="campaign-title">${this.esc(c.title)}</h1>
<p class="campaign-desc">${this.esc(c.description)}</p>
<div class="campaign-stats">
<span>📅 ${this.esc(c.duration)}</span>
<span>📱 ${c.platforms.join(', ')}</span>
<span>📝 ${c.posts.length} posts across ${c.phases.length} phases</span>
</div>
</div>
</div>
<div class="actions">
<a href="/${this.esc(this._space)}/rsocials/thread" class="btn btn--outline">Open Thread Builder</a>
<button class="btn btn--primary" id="import-md-btn">Import from Markdown</button>
</div>
${phaseHTML}
<div id="imported-posts"></div>`;
}
private render() {
if (!this.shadowRoot) return;
const c = this._campaigns[0] || { ...MYCOFI_CAMPAIGN, createdAt: Date.now(), updatedAt: Date.now() };
const campaignHTML = this.renderCampaign(c);
this.shadowRoot.innerHTML = `
<style>
:host { display: block; }
.container { max-width: 900px; margin: 0 auto; padding: 2rem 1rem; }
.campaign-header { display: flex; align-items: flex-start; gap: 1rem; margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid var(--rs-input-border, #334155); }
.campaign-icon { font-size: 3rem; }
.campaign-title { margin: 0; font-size: 1.5rem; color: var(--rs-text-primary, #f1f5f9); }
.campaign-desc { margin: 0.25rem 0 0.5rem; color: var(--rs-text-secondary, #94a3b8); font-size: 0.9rem; line-height: 1.5; }
.campaign-stats { display: flex; flex-wrap: wrap; gap: 1rem; font-size: 0.8rem; color: var(--rs-text-muted, #64748b); }
.phase { margin-bottom: 2rem; }
.phase__title { font-size: 1.15rem; color: var(--rs-text-primary, #f1f5f9); margin: 0 0 1rem; display: flex; align-items: center; gap: 0.5rem; }
.phase__days { font-size: 0.8rem; color: var(--rs-text-muted, #64748b); font-weight: 400; }
.phase__posts { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.75rem; }
.post {
background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-input-border, #334155); border-radius: 0.75rem; padding: 1rem;
transition: border-color 0.15s;
}
.post:hover { border-color: #6366f1; }
.post__header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
.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;
}
.post__meta { flex: 1; min-width: 0; }
.post__meta strong { display: block; font-size: 0.8rem; color: var(--rs-text-primary, #f1f5f9); text-transform: capitalize; }
.post__date { font-size: 0.7rem; color: var(--rs-text-muted, #64748b); }
.post__step { font-size: 0.65rem; color: #6366f1; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; }
.status { font-size: 0.6rem; padding: 2px 6px; border-radius: 4px; text-transform: uppercase; font-weight: 600; letter-spacing: 0.05em; white-space: nowrap; }
.status--scheduled { background: rgba(16,185,129,0.15); color: #34d399; }
.status--draft { background: rgba(251,191,36,0.15); color: #fbbf24; }
.post__content { font-size: 0.8rem; color: var(--rs-text-secondary, #94a3b8); line-height: 1.5; margin: 0 0 0.5rem; }
.post__tags { display: flex; flex-wrap: wrap; gap: 0.25rem; }
.tag { font-size: 0.65rem; color: #7dd3fc; }
.actions { display: flex; gap: 0.75rem; margin-bottom: 1.5rem; }
.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;
}
.btn--primary { background: #6366f1; color: white; border: none; }
.btn--primary:hover { background: #818cf8; }
.btn--outline { background: transparent; color: var(--rs-text-secondary, #94a3b8); border: 1px solid var(--rs-input-border, #334155); }
.btn--outline:hover { border-color: #6366f1; color: #c4b5fd; }
/* Import modal */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex;
align-items: center; justify-content: center; z-index: 1000;
}
.modal-overlay[hidden] { display: none; }
.modal {
background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-input-border, #334155); border-radius: 0.75rem;
padding: 1.5rem; width: 90%; max-width: 540px; display: flex; flex-direction: column; gap: 1rem;
}
.modal__header { display: flex; align-items: center; justify-content: space-between; }
.modal__header h3 { margin: 0; font-size: 1.1rem; color: var(--rs-text-primary, #f1f5f9); }
.modal__close {
background: none; border: none; color: var(--rs-text-muted, #64748b); font-size: 1.5rem; cursor: pointer;
line-height: 1; padding: 0;
}
.modal__close:hover { color: var(--rs-text-primary, #f1f5f9); }
.modal__textarea {
width: 100%; min-height: 200px; background: var(--rs-input-bg, #0f172a); color: var(--rs-input-text, #f1f5f9); border: 1px solid var(--rs-input-border, #334155);
border-radius: 8px; padding: 0.75rem; font-family: inherit; font-size: 0.85rem; resize: vertical;
line-height: 1.5; box-sizing: border-box;
}
.modal__textarea:focus { outline: none; border-color: #6366f1; }
.modal__textarea::placeholder { color: var(--rs-text-muted, #64748b); }
.modal__row { display: flex; gap: 0.75rem; align-items: center; }
.modal__select {
flex: 1; background: var(--rs-input-bg, #0f172a); color: var(--rs-input-text, #f1f5f9); border: 1px solid var(--rs-input-border, #334155);
border-radius: 8px; padding: 0.5rem 0.75rem; font-size: 0.85rem;
}
.modal__select:focus { outline: none; border-color: #6366f1; }
</style>
<div class="container">
${campaignHTML}
</div>
<div class="modal-overlay" id="import-modal" hidden>
<div class="modal">
<div class="modal__header">
<h3>Import from Markdown</h3>
<button class="modal__close" id="import-modal-close">&times;</button>
</div>
<textarea class="modal__textarea" id="import-md-textarea" placeholder="Paste tweets separated by ---\n\nFirst tweet\n---\nSecond tweet\n---\nThird tweet"></textarea>
<div class="modal__row">
<select class="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="btn btn--primary" id="import-parse-btn">Parse &amp; Add</button>
</div>
</div>
</div>
`;
this.bindEvents();
}
private bindEvents() {
if (!this.shadowRoot) return;
const modal = this.shadowRoot.getElementById('import-modal') as HTMLElement;
const openBtn = this.shadowRoot.getElementById('import-md-btn');
const closeBtn = this.shadowRoot.getElementById('import-modal-close');
const parseBtn = this.shadowRoot.getElementById('import-parse-btn');
const mdInput = this.shadowRoot.getElementById('import-md-textarea') as HTMLTextAreaElement;
const platformSel = this.shadowRoot.getElementById('import-platform') as HTMLSelectElement;
const importedEl = this.shadowRoot.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; });
parseBtn?.addEventListener('click', () => {
const raw = mdInput.value;
const tweets = raw.split(/\n---\n/).map(t => t.trim()).filter(Boolean);
if (!tweets.length || !importedEl) return;
const platform = platformSel.value;
const total = tweets.length;
// Build imported posts as campaign posts and save to Automerge
const posts: CampaignPost[] = tweets.map((text, i) => ({
id: `imported-${Date.now()}-${i}`,
platform,
postType: 'text',
stepNumber: i + 1,
content: text,
scheduledAt: new Date().toISOString(),
status: 'imported',
hashtags: [],
phase: 1,
phaseLabel: 'Imported',
}));
// Render imported posts inline
let html = `<div class="phase"><h3 class="phase__title">📥 Imported Posts (${total})</h3>`;
html += '<div class="phase__posts">';
tweets.forEach((text, i) => {
const preview = text.length > 180 ? this.esc(text.substring(0, 180)) + '...' : this.esc(text);
html += `<div class="post">
<div class="post__header">
<span class="post__platform" style="background:#6366f1">${this.esc(platform.charAt(0).toUpperCase())}</span>
<div class="post__meta"><strong>${this.esc(platform)}</strong></div>
<span class="status status--draft">imported</span>
</div>
<div class="post__step">Tweet ${i + 1}/${total}</div>
<p class="post__content">${preview.replace(/\n/g, '<br>')}</p>
</div>`;
});
html += '</div></div>';
importedEl.innerHTML = html;
modal.hidden = true;
// Save to Automerge if runtime available
if (this._space !== 'demo') {
const c = this._campaigns[0];
if (c) {
c.posts = [...c.posts, ...posts];
this.saveCampaignToDoc(c);
}
}
});
}
}
customElements.define('folk-campaign-manager', FolkCampaignManager);

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,175 @@
/**
* <folk-thread-gallery> Thread listing grid with cards.
*
* Subscribes to Automerge doc and renders all threads sorted by updatedAt.
* Falls back to demo data when space=demo.
*/
import { socialsSchema, socialsDocId } from '../schemas';
import type { SocialsDoc, ThreadData } from '../schemas';
import type { DocumentId } from '../../../shared/local-first/document';
import { DEMO_FEED } from '../lib/types';
export class FolkThreadGallery extends HTMLElement {
private _space = 'demo';
private _threads: ThreadData[] = [];
private _offlineUnsub: (() => void) | null = null;
static get observedAttributes() { return ['space']; }
connectedCallback() {
this.attachShadow({ mode: 'open' });
this._space = this.getAttribute('space') || 'demo';
this.render();
if (this._space === 'demo') {
this.loadDemoData();
} else {
this.subscribeOffline();
}
}
disconnectedCallback() {
this._offlineUnsub?.();
this._offlineUnsub = null;
}
attributeChangedCallback(name: string, _old: string, val: string) {
if (name === 'space') this._space = val;
}
private async subscribeOffline() {
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime?.isInitialized) return;
try {
const docId = socialsDocId(this._space) as DocumentId;
const doc = await runtime.subscribe(docId, socialsSchema);
this.renderFromDoc(doc);
this._offlineUnsub = runtime.onChange(docId, (updated: any) => {
this.renderFromDoc(updated);
});
} catch {
// Runtime unavailable
}
}
private renderFromDoc(doc: SocialsDoc) {
if (!doc?.threads) return;
this._threads = Object.values(doc.threads).sort((a, b) => b.updatedAt - a.updatedAt);
this.render();
}
private loadDemoData() {
this._threads = [
{
id: 'demo-1', name: 'Alice', handle: '@alice',
title: 'Building local-first apps with rSpace',
tweets: ['Just deployed the new rFlows river view! The enoughness score is such a powerful concept.', 'The key insight: local-first means your data is always available, even offline.', 'And with Automerge, real-time sync just works. No conflict resolution needed.'],
createdAt: Date.now() - 86400000, updatedAt: Date.now() - 3600000,
},
{
id: 'demo-2', name: 'Bob', handle: '@bob',
title: 'Why cosmolocal production matters',
tweets: ['The cosmolocal print network now has 6 providers across 4 countries.', 'Design global, manufacture local — this is the future of sustainable production.'],
createdAt: Date.now() - 172800000, updatedAt: Date.now() - 86400000,
},
{
id: 'demo-3', name: 'Carol', handle: '@carol',
title: 'Governance lessons from Elinor Ostrom',
tweets: ['Reading "Governing the Commons" — so many parallels to what we\'re building.', 'Ostrom\'s 8 principles for managing commons map perfectly to DAO governance.', 'The key: graduated sanctions and local monitoring. Not one-size-fits-all.'],
createdAt: Date.now() - 259200000, updatedAt: Date.now() - 172800000,
},
];
this.render();
}
private esc(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
private render() {
if (!this.shadowRoot) return;
const space = this._space;
const threads = this._threads;
const cardsHTML = threads.length === 0
? `<div class="empty">
<p>No threads yet. Create your first thread!</p>
<a href="/${this.esc(space)}/rsocials/thread" class="btn btn--success">Create Thread</a>
</div>`
: `<div class="grid">
${threads.map(t => {
const initial = (t.name || '?').charAt(0).toUpperCase();
const preview = this.esc((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="card__image"><img src="${this.esc(t.imageUrl)}" alt="" loading="lazy"></div>`
: '';
return `<a href="/${this.esc(space)}/rsocials/thread/${this.esc(t.id)}" class="card">
${imageTag}
<h3 class="card__title">${this.esc(t.title || 'Untitled Thread')}</h3>
<p class="card__preview">${preview}</p>
<div class="card__meta">
<div class="card__author">
<div class="card__avatar">${this.esc(initial)}</div>
<span>${this.esc(t.handle || t.name || 'Anonymous')}</span>
</div>
<span>${t.tweets.length} tweet${t.tweets.length === 1 ? '' : 's'}</span>
<span>${dateStr}</span>
</div>
</a>`;
}).join('')}
</div>`;
this.shadowRoot.innerHTML = `
<style>
:host { display: block; }
.gallery { max-width: 900px; margin: 0 auto; padding: 2rem 1rem; }
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 0.75rem; }
.header h1 {
margin: 0; font-size: 1.5rem;
background: linear-gradient(135deg, #7dd3fc, #c4b5fd);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
.btn { padding: 0.5rem 1rem; border-radius: 8px; border: none; font-size: 0.85rem; font-weight: 600; cursor: pointer; transition: all 0.15s; text-decoration: none; display: inline-flex; align-items: center; }
.btn--primary { background: #6366f1; color: white; }
.btn--primary:hover { background: #818cf8; }
.btn--success { background: #10b981; color: white; }
.btn--success:hover { background: #34d399; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; }
.empty { color: var(--rs-text-muted, #64748b); text-align: center; padding: 3rem 1rem; font-size: 0.9rem; }
.card {
background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-input-border, #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;
}
.card:hover { border-color: #6366f1; transform: translateY(-2px); }
.card__title { font-size: 1rem; font-weight: 700; color: var(--rs-text-primary, #f1f5f9); margin: 0; line-height: 1.3; }
.card__preview {
font-size: 0.85rem; color: var(--rs-text-secondary, #94a3b8); line-height: 1.5;
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
}
.card__meta { display: flex; align-items: center; gap: 0.75rem; font-size: 0.75rem; color: var(--rs-text-muted, #64748b); margin-top: auto; }
.card__author { display: flex; align-items: center; gap: 0.4rem; }
.card__avatar {
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;
}
.card__image { border-radius: 8px; overflow: hidden; border: 1px solid var(--rs-input-border, #334155); margin-bottom: 0.25rem; }
.card__image img { display: block; width: 100%; height: 120px; object-fit: cover; }
</style>
<div class="gallery">
<div class="header">
<h1>Threads</h1>
<a href="/${this.esc(space)}/rsocials/thread" class="btn btn--primary">New Thread</a>
</div>
${cardsHTML}
</div>
`;
}
}
customElements.define('folk-thread-gallery', FolkThreadGallery);

View File

@ -0,0 +1,368 @@
/**
* rSocials all extracted CSS.
*
* Combined from inline CSS blocks previously in mod.ts:
* - Campaign page styles
* - Thread builder styles
* - Thread read-only styles
* - Thread gallery styles
* - Demo feed styles
*/
/* ── Campaign page ── */
.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 var(--rs-input-border); }
.campaign-page__icon { font-size: 3rem; }
.campaign-page__title { margin: 0; font-size: 1.5rem; color: var(--rs-text-primary); }
.campaign-page__desc { margin: 0.25rem 0 0.5rem; color: var(--rs-text-secondary); font-size: 0.9rem; line-height: 1.5; }
.campaign-page__stats { display: flex; flex-wrap: wrap; gap: 1rem; font-size: 0.8rem; color: var(--rs-text-muted); }
.campaign-phase { margin-bottom: 2rem; }
.campaign-phase__title { font-size: 1.15rem; color: var(--rs-text-primary); margin: 0 0 1rem; display: flex; align-items: center; gap: 0.5rem; }
.campaign-phase__days { font-size: 0.8rem; color: var(--rs-text-muted); font-weight: 400; }
.campaign-phase__posts { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.75rem; }
.campaign-post {
background: var(--rs-bg-surface); border: 1px solid var(--rs-input-border); 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: var(--rs-text-primary); text-transform: capitalize; }
.campaign-post__date { font-size: 0.7rem; color: var(--rs-text-muted); }
.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: var(--rs-text-secondary); 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: var(--rs-text-secondary); border: 1px solid var(--rs-input-border); }
.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: var(--rs-bg-surface); border: 1px solid var(--rs-input-border); 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: var(--rs-text-primary); }
.campaign-modal__close {
background: none; border: none; color: var(--rs-text-muted); font-size: 1.5rem; cursor: pointer;
line-height: 1; padding: 0;
}
.campaign-modal__close:hover { color: var(--rs-text-primary); }
.campaign-modal__textarea {
width: 100%; min-height: 200px; background: var(--rs-input-bg); color: var(--rs-input-text); border: 1px solid var(--rs-input-border);
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: var(--rs-text-muted); }
.campaign-modal__row { display: flex; gap: 0.75rem; align-items: center; }
.campaign-modal__select {
flex: 1; background: var(--rs-input-bg); color: var(--rs-input-text); border: 1px solid var(--rs-input-border);
border-radius: 8px; padding: 0.5rem 0.75rem; font-size: 0.85rem;
}
.campaign-modal__select:focus { outline: none; border-color: #6366f1; }
/* ── Thread builder ── */
.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: var(--rs-text-primary); 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: var(--rs-text-secondary); border: 1px solid var(--rs-input-border); }
.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: var(--rs-bg-surface); color: var(--rs-text-primary); border: 1px solid var(--rs-input-border);
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: var(--rs-text-muted); }
.thread-compose__fields { display: flex; gap: 0.75rem; }
.thread-compose__input {
flex: 1; background: var(--rs-bg-surface); color: var(--rs-text-primary); border: 1px solid var(--rs-input-border);
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: var(--rs-text-muted); }
.thread-compose__title {
width: 100%; background: var(--rs-bg-surface); color: var(--rs-text-primary); border: 1px solid var(--rs-input-border);
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: var(--rs-text-muted); }
.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: var(--rs-text-muted); 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: var(--rs-bg-surface); border: 1px solid var(--rs-input-border); 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: var(--rs-text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.thread-draft-item__info span { font-size: 0.7rem; color: var(--rs-text-muted); }
.thread-draft-item__delete {
background: none; border: none; color: var(--rs-text-muted); 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 var(--rs-input-border); }
.thread-image-preview[hidden] { display: none; }
.thread-image-preview img { display: block; max-width: 200px; height: auto; }
#share-link-area { grid-column: 1 / -1; }
.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: var(--rs-text-secondary); cursor: pointer; font-size: 0.75rem; padding: 2px 6px;
}
.thread-share-link button:hover { color: var(--rs-text-primary); }
.thread-preview { display: flex; flex-direction: column; gap: 0; }
.thread-preview__empty { color: var(--rs-text-muted); text-align: center; padding: 3rem 1rem; font-size: 0.9rem; }
.tweet-card {
position: relative; background: var(--rs-bg-surface); border: 1px solid var(--rs-input-border); 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: var(--rs-input-border); 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: var(--rs-text-primary); font-size: 0.9rem; }
.tweet-card__handle { color: var(--rs-text-muted); font-size: 0.85rem; }
.tweet-card__dot { color: var(--rs-text-muted); font-size: 0.85rem; }
.tweet-card__time { color: var(--rs-text-muted); font-size: 0.85rem; }
.tweet-card__content { color: var(--rs-text-primary); 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: var(--rs-text-muted); 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: var(--rs-text-muted); }
.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: var(--rs-bg-surface); border: 1px solid var(--rs-input-border); border-radius: 8px;
min-width: 180px; overflow: hidden;
box-shadow: 0 8px 24px var(--rs-shadow-lg);
}
.thread-export-menu[hidden] { display: none; }
.thread-export-menu button {
display: block; width: 100%; padding: 0.6rem 0.75rem; border: none;
background: transparent; color: var(--rs-text-primary); 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 var(--rs-bg-hover); }
.tweet-card__photo-btn {
position: absolute; top: 8px; right: 8px; z-index: 5;
width: 28px; height: 28px; border-radius: 50%; border: 1px solid var(--rs-input-border);
background: var(--rs-bg-surface); color: var(--rs-text-muted); cursor: pointer;
display: flex; align-items: center; justify-content: center;
opacity: 0; transition: opacity 0.15s, border-color 0.15s, color 0.15s;
}
.tweet-card:hover .tweet-card__photo-btn { opacity: 1; }
.tweet-card__photo-btn:hover { border-color: #6366f1; color: #c4b5fd; }
.tweet-card__photo-btn svg { width: 14px; height: 14px; }
.tweet-card__photo-btn .photo-btn-plus {
position: absolute; bottom: -1px; right: -3px; font-size: 10px; font-weight: 700;
color: #6366f1; line-height: 1;
}
.tweet-card__photo-menu {
position: absolute; top: 38px; right: 8px; z-index: 10;
background: var(--rs-bg-surface); border: 1px solid var(--rs-input-border); border-radius: 8px;
min-width: 160px; overflow: hidden; box-shadow: 0 8px 24px var(--rs-shadow-lg);
}
.tweet-card__photo-menu[hidden] { display: none; }
.tweet-card__photo-menu button {
display: flex; align-items: center; gap: 0.4rem; width: 100%;
padding: 0.5rem 0.7rem; border: none; background: transparent;
color: var(--rs-text-primary); font-size: 0.8rem; cursor: pointer; transition: background 0.1s;
}
.tweet-card__photo-menu button:hover { background: rgba(99,102,241,0.15); }
.tweet-card__photo-menu button + button { border-top: 1px solid var(--rs-bg-hover); }
.tweet-card__photo-menu button svg { width: 14px; height: 14px; }
.tweet-card__attached-image { position: relative; margin-top: 0.5rem; border-radius: 8px; overflow: hidden; border: 1px solid var(--rs-input-border); }
.tweet-card__attached-image img { display: block; width: 100%; height: auto; }
.tweet-card__image-remove {
position: absolute; top: 6px; right: 6px; width: 22px; height: 22px;
border-radius: 50%; background: rgba(0,0,0,0.7); color: white; border: none;
font-size: 0.8rem; cursor: pointer; display: flex; align-items: center;
justify-content: center; line-height: 1; transition: background 0.15s;
}
.tweet-card__image-remove:hover { background: #ef4444; }
/* ── Thread read-only ── */
.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: var(--rs-text-primary); font-size: 1.1rem; }
.thread-ro__handle { color: var(--rs-text-muted); font-size: 0.9rem; }
.thread-ro__meta { display: flex; align-items: center; gap: 0.5rem; color: var(--rs-text-muted); font-size: 0.85rem; }
.thread-ro__title { font-size: 1.4rem; color: var(--rs-text-primary); margin: 0 0 1.5rem; line-height: 1.3; }
.thread-ro__image { margin-bottom: 1.5rem; border-radius: 12px; overflow: hidden; border: 1px solid var(--rs-input-border); }
.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 var(--rs-input-border); }
.thread-ro__cta { display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap; }
.thread-export-toast {
position: fixed; bottom: 1.5rem; left: 50%; transform: translateX(-50%);
background: var(--rs-bg-surface); border: 1px solid #6366f1; color: #c4b5fd;
padding: 0.6rem 1.25rem; border-radius: 8px; font-size: 0.85rem;
box-shadow: 0 4px 16px var(--rs-shadow-lg); z-index: 1000;
transition: opacity 0.2s;
}
.thread-export-toast[hidden] { display: none; }
/* ── Thread gallery ── */
.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: var(--rs-text-muted); text-align: center; padding: 3rem 1rem; font-size: 0.9rem; }
.thread-card {
background: var(--rs-bg-surface); border: 1px solid var(--rs-input-border); 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: var(--rs-text-primary); margin: 0; line-height: 1.3; }
.thread-card__preview {
font-size: 0.85rem; color: var(--rs-text-secondary); 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: var(--rs-text-muted); 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 var(--rs-input-border); margin-bottom: 0.25rem; }
.thread-card__image img { display: block; width: 100%; height: 120px; object-fit: cover; }
/* ── Demo feed ── */
.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: var(--rs-text-muted); font-size: 0.85rem; margin: 0; }
.rsocials-feed { display: flex; flex-direction: column; gap: 1px; }
.rsocials-loading { color: var(--rs-text-muted); padding: 2rem 0; text-align: center; }
.rsocials-empty { color: var(--rs-text-muted); padding: 2rem 0; text-align: center; }
.rsocials-item {
padding: 1rem; border-radius: 8px;
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-subtle);
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: var(--rs-text-secondary); 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: var(--rs-text-primary); font-size: 0.9rem; }
.rsocials-meta time { font-size: 0.75rem; color: var(--rs-text-muted); }
.rsocials-item-header strong { color: var(--rs-text-primary); }
.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: var(--rs-text-primary); 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: var(--rs-text-muted);
}
.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: var(--rs-text-muted);
padding: 1rem 0; border-top: 1px solid var(--rs-border-subtle); margin-top: 0.5rem;
}

View File

@ -0,0 +1,97 @@
/**
* Server-side image generation and file upload helpers.
*
* These functions require filesystem access and FAL_KEY,
* so they stay server-only (not bundled into web components).
*/
import { resolve } from "node:path";
import { mkdir, writeFile, unlink } from "node:fs/promises";
const GEN_DIR = resolve(process.env.FILES_DIR || "./data/files", "generated");
async function ensureGenDir(): Promise<string> {
await mkdir(GEN_DIR, { recursive: true });
return GEN_DIR;
}
// ── fal.ai image generation ──
export async function generateImageFromPrompt(prompt: string): Promise<string | null> {
const FAL_KEY = process.env.FAL_KEY || "";
if (!FAL_KEY) return null;
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("[image-gen] fal.ai error:", await falRes.text());
return null;
}
const falData = await falRes.json() as { images?: { url: string }[]; output?: { url: string } };
return falData.images?.[0]?.url || falData.output?.url || null;
}
export async function downloadAndSaveImage(cdnUrl: string, filename: string): Promise<string | null> {
const imgRes = await fetch(cdnUrl);
if (!imgRes.ok) return null;
const imgBuffer = await imgRes.arrayBuffer();
const dir = await ensureGenDir();
await writeFile(resolve(dir, filename), Buffer.from(imgBuffer));
return `/data/files/generated/${filename}`;
}
// ── File upload handling ──
const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
export function validateImageFile(file: File): string | null {
if (!ALLOWED_TYPES.includes(file.type)) {
return "Invalid file type. Allowed: png, jpg, webp, gif";
}
if (file.size > MAX_SIZE) {
return "File too large. Maximum 5MB";
}
return null;
}
export function safeExtension(filename: string): string {
const ext = filename.split(".").pop()?.toLowerCase() || "png";
return ["png", "jpg", "jpeg", "webp", "gif"].includes(ext) ? ext : "png";
}
export async function saveUploadedFile(buffer: Buffer, filename: string): Promise<string> {
const dir = await ensureGenDir();
await writeFile(resolve(dir, filename), buffer);
return `/data/files/generated/${filename}`;
}
// ── Cleanup helpers ──
export async function deleteImageFile(imageUrl: string): Promise<void> {
const fname = imageUrl.split("/").pop();
if (!fname) return;
try { await unlink(resolve(GEN_DIR, fname)); } catch { /* ignore */ }
}
export async function deleteOldImage(oldUrl: string | undefined, newFilename: string): Promise<void> {
if (!oldUrl) return;
const oldFilename = oldUrl.split("/").pop();
if (oldFilename && oldFilename !== newFilename) {
try { await unlink(resolve(GEN_DIR, oldFilename)); } catch { /* ignore */ }
}
}

View File

@ -0,0 +1,98 @@
/**
* rSocials shared types and constants.
*
* Used by both server (mod.ts) and client (web components).
*/
// ── Feed types ──
export interface FeedItem {
id: string;
type: "post" | "link";
author: string;
content: string;
url?: string;
source: string;
timestamp: string;
likes: number;
replies: number;
}
// ── Demo feed data ──
export interface DemoFeedPost {
username: string;
initial: string;
color: string;
content: string;
timeAgo: string;
likes: number;
replies: number;
}
export const DEMO_FEED: DemoFeedPost[] = [
{
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,
},
];
// ── Platform character limits ──
export const PLATFORM_LIMITS: Record<string, number> = {
twitter: 280,
bluesky: 300,
mastodon: 500,
linkedin: 3000,
plain: Infinity,
};

View File

@ -0,0 +1,143 @@
/**
* rSocials Local-First Client
*
* Wraps the shared local-first stack for thread and campaign data.
* One Automerge doc per space stores all threads and campaigns.
*/
import { DocumentManager } from '../../shared/local-first/document';
import type { DocumentId } from '../../shared/local-first/document';
import { EncryptedDocStore } from '../../shared/local-first/storage';
import { DocSyncManager } from '../../shared/local-first/sync';
import { DocCrypto } from '../../shared/local-first/crypto';
import { socialsSchema, socialsDocId } from './schemas';
import type { SocialsDoc, ThreadData, Campaign } from './schemas';
export class SocialsLocalFirstClient {
#space: string;
#documents: DocumentManager;
#store: EncryptedDocStore;
#sync: DocSyncManager;
#initialized = false;
constructor(space: string, docCrypto?: DocCrypto) {
this.#space = space;
this.#documents = new DocumentManager();
this.#store = new EncryptedDocStore(space, docCrypto);
this.#sync = new DocSyncManager({
documents: this.#documents,
store: this.#store,
});
this.#documents.registerSchema(socialsSchema);
}
get isConnected(): boolean { return this.#sync.isConnected; }
async init(): Promise<void> {
if (this.#initialized) return;
await this.#store.open();
const cachedIds = await this.#store.listByModule('socials', 'data');
const cached = await this.#store.loadMany(cachedIds);
for (const [docId, binary] of cached) {
this.#documents.open<SocialsDoc>(docId, socialsSchema, binary);
}
await this.#sync.preloadSyncStates(cachedIds);
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${location.host}/ws/${this.#space}`;
try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[SocialsClient] Working offline'); }
this.#initialized = true;
}
async subscribe(): Promise<SocialsDoc | null> {
const docId = socialsDocId(this.#space) as DocumentId;
let doc = this.#documents.get<SocialsDoc>(docId);
if (!doc) {
const binary = await this.#store.load(docId);
doc = binary
? this.#documents.open<SocialsDoc>(docId, socialsSchema, binary)
: this.#documents.open<SocialsDoc>(docId, socialsSchema);
}
await this.#sync.subscribe([docId]);
return doc ?? null;
}
// ── Reads ──
getDoc(): SocialsDoc | undefined {
return this.#documents.get<SocialsDoc>(socialsDocId(this.#space) as DocumentId);
}
listThreads(): ThreadData[] {
const doc = this.getDoc();
if (!doc?.threads) return [];
return Object.values(doc.threads).sort((a, b) => b.updatedAt - a.updatedAt);
}
getThread(id: string): ThreadData | undefined {
const doc = this.getDoc();
return doc?.threads?.[id];
}
listCampaigns(): Campaign[] {
const doc = this.getDoc();
if (!doc?.campaigns) return [];
return Object.values(doc.campaigns).sort((a, b) => b.updatedAt - a.updatedAt);
}
getCampaign(id: string): Campaign | undefined {
const doc = this.getDoc();
return doc?.campaigns?.[id];
}
// ── Thread writes ──
saveThread(thread: ThreadData): void {
const docId = socialsDocId(this.#space) as DocumentId;
this.#sync.change<SocialsDoc>(docId, `Save thread ${thread.title || thread.id}`, (d) => {
if (!d.threads) d.threads = {} as any;
thread.updatedAt = Date.now();
if (!thread.createdAt) thread.createdAt = Date.now();
d.threads[thread.id] = thread;
});
}
deleteThread(id: string): void {
const docId = socialsDocId(this.#space) as DocumentId;
this.#sync.change<SocialsDoc>(docId, `Delete thread ${id}`, (d) => {
if (d.threads?.[id]) delete d.threads[id];
});
}
// ── Campaign writes ──
saveCampaign(campaign: Campaign): void {
const docId = socialsDocId(this.#space) as DocumentId;
this.#sync.change<SocialsDoc>(docId, `Save campaign ${campaign.title || campaign.id}`, (d) => {
if (!d.campaigns) d.campaigns = {} as any;
campaign.updatedAt = Date.now();
if (!campaign.createdAt) campaign.createdAt = Date.now();
d.campaigns[campaign.id] = campaign;
});
}
deleteCampaign(id: string): void {
const docId = socialsDocId(this.#space) as DocumentId;
this.#sync.change<SocialsDoc>(docId, `Delete campaign ${id}`, (d) => {
if (d.campaigns?.[id]) delete d.campaigns[id];
});
}
// ── Events ──
onChange(cb: (doc: SocialsDoc) => void): () => void {
return this.#sync.onChange(socialsDocId(this.#space) as DocumentId, cb as (doc: any) => void);
}
onConnect(cb: () => void): () => void { return this.#sync.onConnect(cb); }
onDisconnect(cb: () => void): () => void { return this.#sync.onDisconnect(cb); }
async disconnect(): Promise<void> {
await this.#sync.flush();
this.#sync.disconnect();
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,90 @@
/**
* rSocials Automerge document schemas.
*
* Granularity: one Automerge document per space.
* DocId format: {space}:socials:data
*
* Images stay on filesystem, referenced by URL strings in the doc.
*/
import type { DocSchema } from '../../shared/local-first/document';
// ── Thread types ──
export interface ThreadData {
id: string;
name: string;
handle: string;
title: string;
tweets: string[];
imageUrl?: string;
tweetImages?: Record<string, string>;
createdAt: number;
updatedAt: number;
}
// ── Campaign types ──
export interface CampaignPost {
id: string;
platform: string;
postType: string;
stepNumber: number;
content: string;
scheduledAt: string;
status: string;
hashtags: string[];
phase: number;
phaseLabel: string;
}
export interface Campaign {
id: string;
title: string;
description: string;
duration: string;
platforms: string[];
phases: { name: string; label: string; days: string }[];
posts: CampaignPost[];
createdAt: number;
updatedAt: number;
}
// ── Document root ──
export interface SocialsDoc {
meta: {
module: string;
collection: string;
version: number;
spaceSlug: string;
createdAt: number;
};
threads: Record<string, ThreadData>;
campaigns: Record<string, Campaign>;
}
// ── Schema registration ──
export const socialsSchema: DocSchema<SocialsDoc> = {
module: 'socials',
collection: 'data',
version: 1,
init: (): SocialsDoc => ({
meta: {
module: 'socials',
collection: 'data',
version: 1,
spaceSlug: '',
createdAt: Date.now(),
},
threads: {},
campaigns: {},
}),
};
// ── Helpers ──
export function socialsDocId(space: string) {
return `${space}:socials:data` as const;
}

View File

@ -568,7 +568,7 @@ export default defineConfig({
resolve(__dirname, "dist/modules/rnetwork/network.css"),
);
// Build socials canvas component
// Build socials thread builder component
await build({
configFile: false,
root: resolve(__dirname, "modules/rsocials/components"),
@ -576,23 +576,63 @@ export default defineConfig({
emptyOutDir: false,
outDir: resolve(__dirname, "dist/modules/rsocials"),
lib: {
entry: resolve(__dirname, "modules/rsocials/components/folk-socials-canvas.ts"),
entry: resolve(__dirname, "modules/rsocials/components/folk-thread-builder.ts"),
formats: ["es"],
fileName: () => "folk-socials-canvas.js",
fileName: () => "folk-thread-builder.js",
},
rollupOptions: {
output: {
entryFileNames: "folk-socials-canvas.js",
entryFileNames: "folk-thread-builder.js",
},
},
},
});
// Copy socials canvas CSS
// Build socials thread gallery component
await build({
configFile: false,
root: resolve(__dirname, "modules/rsocials/components"),
build: {
emptyOutDir: false,
outDir: resolve(__dirname, "dist/modules/rsocials"),
lib: {
entry: resolve(__dirname, "modules/rsocials/components/folk-thread-gallery.ts"),
formats: ["es"],
fileName: () => "folk-thread-gallery.js",
},
rollupOptions: {
output: {
entryFileNames: "folk-thread-gallery.js",
},
},
},
});
// Build socials campaign manager component
await build({
configFile: false,
root: resolve(__dirname, "modules/rsocials/components"),
build: {
emptyOutDir: false,
outDir: resolve(__dirname, "dist/modules/rsocials"),
lib: {
entry: resolve(__dirname, "modules/rsocials/components/folk-campaign-manager.ts"),
formats: ["es"],
fileName: () => "folk-campaign-manager.js",
},
rollupOptions: {
output: {
entryFileNames: "folk-campaign-manager.js",
},
},
},
});
// Copy socials CSS
mkdirSync(resolve(__dirname, "dist/modules/rsocials"), { recursive: true });
copyFileSync(
resolve(__dirname, "modules/rsocials/components/socials-canvas.css"),
resolve(__dirname, "dist/modules/rsocials/socials-canvas.css"),
resolve(__dirname, "modules/rsocials/components/socials.css"),
resolve(__dirname, "dist/modules/rsocials/socials.css"),
);
// Build tube module component