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

176 lines
7.2 KiB
TypeScript

/**
* <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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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);