256 lines
9.8 KiB
TypeScript
256 lines
9.8 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[];
|
||
}
|
||
|
||
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, '&').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 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 { 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: 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);
|