357 lines
14 KiB
TypeScript
357 lines
14 KiB
TypeScript
/**
|
||
* <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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
}
|
||
|
||
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);
|