rspace-online/modules/rsocials/components/folk-thread-gallery.ts

256 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* <folk-thread-gallery> — Thread listing grid with cards.
*
* Subscribes to Automerge doc and renders all threads sorted by updatedAt.
* Falls back to static demo previews when offline runtime is unavailable.
*/
import { socialsSchema, socialsDocId } from '../schemas';
import type { SocialsDoc, ThreadData, Campaign, CampaignPost } from '../schemas';
import type { DocumentId } from '../../../shared/local-first/document';
import { DEMO_FEED } from '../lib/types';
interface DraftPostCard {
id: string;
campaignId: string;
campaignTitle: string;
platform: string;
content: string;
scheduledAt: string;
status: string;
hashtags: string[];
}
export class FolkThreadGallery extends HTMLElement {
private _space = 'demo';
private _threads: ThreadData[] = [];
private _draftPosts: DraftPostCard[] = [];
private _offlineUnsub: (() => void) | null = null;
private _isDemoFallback = false;
static get observedAttributes() { return ['space']; }
connectedCallback() {
if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
this._space = this.getAttribute('space') || 'demo';
this.render();
this.subscribeOffline();
}
disconnectedCallback() {
this._offlineUnsub?.();
this._offlineUnsub = null;
}
attributeChangedCallback(name: string, _old: string, val: string) {
if (name === 'space') this._space = val;
}
private async subscribeOffline() {
let runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime) {
await new Promise(r => setTimeout(r, 200));
runtime = (window as any).__rspaceOfflineRuntime;
}
if (!runtime) { this.loadDemoFallback(); return; }
if (!runtime.isInitialized && runtime.init) {
try { await runtime.init(); } catch { /* already init'd */ }
}
if (!runtime.isInitialized) { this.loadDemoFallback(); 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 {
this.loadDemoFallback();
}
}
private loadDemoFallback() {
if (this._threads.length > 0) return; // already have data
// No runtime available — show static demo content (not editable)
this._threads = DEMO_FEED.slice(0, 3).map((item, i) => ({
id: `demo-${i}`,
name: item.username.replace('@', ''),
handle: item.username,
title: item.content.substring(0, 60),
tweets: [item.content],
createdAt: Date.now() - (i + 1) * 86400000,
updatedAt: Date.now() - i * 3600000,
}));
this._isDemoFallback = true;
this.render();
}
private renderFromDoc(doc: SocialsDoc) {
if (!doc?.threads) return;
this._isDemoFallback = false;
this._threads = Object.values(doc.threads).sort((a, b) => b.updatedAt - a.updatedAt);
// Extract draft/scheduled posts from campaigns
this._draftPosts = [];
if (doc.campaigns) {
for (const campaign of Object.values(doc.campaigns)) {
for (const post of campaign.posts || []) {
if (post.status === 'draft' || post.status === 'scheduled') {
this._draftPosts.push({
id: post.id,
campaignId: campaign.id,
campaignTitle: campaign.title,
platform: post.platform,
content: post.content,
scheduledAt: post.scheduledAt,
status: post.status,
hashtags: post.hashtags || [],
});
}
}
}
}
this.render();
}
private get basePath() {
const host = window.location.hostname;
if (host.endsWith('.rspace.online') || host.endsWith('.rsocials.online')) {
return '/rsocials/';
}
return `/${this._space}/rsocials/`;
}
private esc(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
private platformIcon(platform: string): string {
const icons: Record<string, string> = {
x: '𝕏', twitter: '𝕏', linkedin: '💼', instagram: '📷',
threads: '🧵', bluesky: '🦋', youtube: '📹', newsletter: '📧',
};
return icons[platform.toLowerCase()] || '📱';
}
private render() {
if (!this.shadowRoot) return;
const space = this._space;
const threads = this._threads;
const drafts = this._draftPosts;
const threadCardsHTML = threads.length === 0 && drafts.length === 0
? `<div class="empty">
<p>No posts or threads yet. Create your first thread!</p>
<a href="${this.basePath}thread-editor" class="btn btn--success">Create Thread</a>
</div>`
: `<div class="grid">
${drafts.map(p => {
const preview = this.esc(p.content.substring(0, 200));
const schedDate = p.scheduledAt ? new Date(p.scheduledAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '';
const statusBadge = p.status === 'scheduled'
? '<span class="badge badge--scheduled">Scheduled</span>'
: '<span class="badge badge--draft">Draft</span>';
return `<div class="card card--draft">
<div class="card__badges">
${statusBadge}
<span class="badge badge--campaign">${this.esc(p.campaignTitle)}</span>
</div>
<h3 class="card__title">${this.platformIcon(p.platform)} ${this.esc(p.platform)} Post</h3>
<p class="card__preview">${preview}</p>
<div class="card__meta">
${p.hashtags.length ? `<span>${p.hashtags.slice(0, 3).join(' ')}</span>` : ''}
${schedDate ? `<span>${schedDate}</span>` : ''}
</div>
</div>`;
}).join('')}
${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>`
: '';
const href = this._isDemoFallback
? `${this.basePath}thread-editor`
: `${this.basePath}thread-editor/${this.esc(t.id)}/edit`;
return `<a href="${href}" class="card" data-collab-id="thread:${this.esc(t.id)}">
${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: var(--rs-primary); color: white; }
.btn--primary:hover { background: var(--rs-primary-hover); }
.btn--success { background: var(--rs-success); color: white; }
.btn--success:hover { opacity: 0.85; }
.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: var(--rs-primary); transform: translateY(-2px); }
.card--draft { border-left: 3px solid #f59e0b; }
.card__badges { display: flex; gap: 0.4rem; flex-wrap: wrap; }
.badge {
font-size: 0.65rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;
padding: 0.15rem 0.5rem; border-radius: 4px;
}
.badge--draft { background: rgba(245,158,11,0.15); color: #f59e0b; }
.badge--scheduled { background: rgba(59,130,246,0.15); color: #60a5fa; }
.badge--campaign { background: rgba(99,102,241,0.15); color: #a5b4fc; }
.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: var(--rs-primary);
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>Posts & Threads</h1>
<a href="${this.basePath}thread-editor" class="btn btn--primary">New Thread</a>
</div>
${threadCardsHTML}
</div>
`;
}
}
customElements.define('folk-thread-gallery', FolkThreadGallery);