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