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

357 lines
14 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[];
threadId?: string;
}
type StatusFilter = 'all' | 'draft' | 'scheduled' | 'published';
const STATUS_STORAGE_KEY = 'rsocials:gallery:status-filter';
export class FolkThreadGallery extends HTMLElement {
private _space = 'demo';
private _threads: ThreadData[] = [];
private _allPosts: DraftPostCard[] = [];
private _offlineUnsub: (() => void) | null = null;
private _subscribedDocIds: string[] = [];
private _isDemoFallback = false;
private _statusFilter: StatusFilter = 'all';
static get observedAttributes() { return ['space']; }
connectedCallback() {
if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
this._space = this.getAttribute('space') || 'demo';
// Restore last status filter
try {
const saved = localStorage.getItem(STATUS_STORAGE_KEY);
if (saved === 'draft' || saved === 'scheduled' || saved === 'published' || saved === 'all') {
this._statusFilter = saved;
}
} catch { /* ignore */ }
this.render();
this.subscribeOffline();
}
disconnectedCallback() {
this._offlineUnsub?.();
this._offlineUnsub = null;
const runtime = (window as any).__rspaceOfflineRuntime;
if (runtime) {
for (const id of this._subscribedDocIds) runtime.unsubscribe(id);
}
this._subscribedDocIds = [];
}
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._subscribedDocIds.push(docId);
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 all campaign posts across statuses so the status filter
// can choose between drafts / scheduled / published.
this._allPosts = [];
if (doc.campaigns) {
for (const campaign of Object.values(doc.campaigns)) {
for (const post of campaign.posts || []) {
this._allPosts.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 || [],
threadId: post.threadId,
});
}
}
}
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 threads = this._threads;
const filter = this._statusFilter;
// Filter + sort posts by status + recency
const filteredPosts = this._allPosts
.filter(p => filter === 'all' || p.status === filter)
.sort((a, b) => {
const aT = a.scheduledAt ? new Date(a.scheduledAt).getTime() : 0;
const bT = b.scheduledAt ? new Date(b.scheduledAt).getTime() : 0;
return bT - aT;
});
// Status counts for chip badges
const counts = {
all: this._allPosts.length,
draft: this._allPosts.filter(p => p.status === 'draft').length,
scheduled: this._allPosts.filter(p => p.status === 'scheduled').length,
published: this._allPosts.filter(p => p.status === 'published').length,
};
const chip = (key: StatusFilter, label: string) =>
`<button type="button" class="chip${filter === key ? ' chip--active' : ''}" data-status="${key}">
${label}<span class="chip__count">${counts[key]}</span>
</button>`;
const filterBar = `<div class="filter-bar" role="toolbar" aria-label="Filter posts by status">
${chip('all', 'All')}
${chip('draft', 'Drafts')}
${chip('scheduled', 'Scheduled')}
${chip('published', 'Published')}
</div>`;
// Threads are shown only when status is "all" or "published" — they don't have draft state in this schema.
const showThreads = filter === 'all' || filter === 'published';
const threadsVisible = showThreads ? threads : [];
const threadCardsHTML = filteredPosts.length === 0 && threadsVisible.length === 0
? `<div class="empty">
<p>${filter === 'all' ? 'No posts or threads yet.' : `No ${filter} posts.`} Create your first post or thread.</p>
<div style="display:flex;gap:.5rem;justify-content:center;margin-top:1rem;flex-wrap:wrap">
<a href="${this.basePath}thread-editor" class="btn btn--success">Create Thread</a>
<a href="${this.basePath}campaigns" class="btn btn--primary">Open Campaigns</a>
</div>
</div>`
: `<div class="grid">
${filteredPosts.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 statusClass = `badge badge--${p.status}`;
const statusLabel = p.status.charAt(0).toUpperCase() + p.status.slice(1);
const cardClass = `card card--${p.status}`;
const href = p.threadId
? `${this.basePath}thread-editor/${this.esc(p.threadId)}/edit`
: `${this.basePath}campaign`;
return `<a href="${href}" class="${cardClass}">
<div class="card__badges">
<span class="${statusClass}">${statusLabel}</span>
<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>
</a>`;
}).join('')}
${threadsVisible.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--scheduled { border-left: 3px solid #60a5fa; }
.card--published { border-left: 3px solid #22c55e; }
.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--published { background: rgba(34,197,94,0.15); color: #22c55e; }
.badge--campaign { background: rgba(99,102,241,0.15); color: #a5b4fc; }
.filter-bar {
display: flex; gap: 0.4rem; flex-wrap: wrap; margin-bottom: 1.25rem;
}
.chip {
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.4rem 0.85rem; border-radius: 999px;
background: var(--rs-bg-surface, #1e293b);
border: 1px solid var(--rs-input-border, #334155);
color: var(--rs-text-secondary, #94a3b8);
font-size: 0.8rem; font-weight: 600;
cursor: pointer; transition: background 0.12s, color 0.12s, border-color 0.12s;
font-family: inherit;
}
.chip:hover { background: rgba(20, 184, 166, 0.08); color: var(--rs-text-primary, #e2e8f0); }
.chip--active {
background: rgba(20, 184, 166, 0.18);
border-color: #14b8a6;
color: #5eead4;
}
.chip__count {
font-size: 0.7rem; font-weight: 700;
background: rgba(148, 163, 184, 0.2);
padding: 0.1rem 0.45rem; border-radius: 999px;
min-width: 1.2rem; text-align: center;
}
.chip--active .chip__count {
background: rgba(94, 234, 212, 0.2);
color: #5eead4;
}
.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>
${filterBar}
${threadCardsHTML}
</div>
`;
// Attach chip click handlers after render
this.shadowRoot.querySelectorAll('.chip').forEach(btn => {
btn.addEventListener('click', () => {
const s = (btn as HTMLElement).dataset.status as StatusFilter | undefined;
if (!s || s === this._statusFilter) return;
this._statusFilter = s;
try { localStorage.setItem(STATUS_STORAGE_KEY, s); } catch { /* ignore */ }
this.render();
});
});
}
}
customElements.define('folk-thread-gallery', FolkThreadGallery);