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:
parent
fff0fd3150
commit
91d414fc88
|
|
@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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">×</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 & 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
|
|
@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 */ }
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue