feat(rsocials): landing shows posts gallery with draft/scheduled/published filter

/rsocials now lands on the content gallery instead of the nav hub.
Gallery surfaces all campaign posts (not just drafts/scheduled) and
adds filter chips for All / Drafts / Scheduled / Published with live
count badges. Last-selected filter persists in localStorage. Cards use
per-status left-border accent (amber/blue/green) and a published badge.

Top-right sub-nav keeps Campaigns / Campaign Canvas / Newsletter one
click away. Bump folk-thread-gallery cache to v=2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-17 21:16:47 -04:00
parent 4eba2cb163
commit 91837fd1d4
2 changed files with 131 additions and 67 deletions

View File

@ -22,19 +22,30 @@ interface DraftPostCard {
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 _draftPosts: DraftPostCard[] = [];
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();
}
@ -100,24 +111,23 @@ export class FolkThreadGallery extends HTMLElement {
this._isDemoFallback = false;
this._threads = Object.values(doc.threads).sort((a, b) => b.updatedAt - a.updatedAt);
// Extract draft/scheduled posts from campaigns
this._draftPosts = [];
// 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 || []) {
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 || [],
threadId: post.threadId,
});
}
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,
});
}
}
}
@ -147,28 +157,63 @@ export class FolkThreadGallery extends HTMLElement {
private render() {
if (!this.shadowRoot) return;
const space = this._space;
const threads = this._threads;
const drafts = this._draftPosts;
const filter = this._statusFilter;
const threadCardsHTML = threads.length === 0 && drafts.length === 0
// 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>No posts or threads yet. Create your first thread!</p>
<a href="${this.basePath}thread-editor" class="btn btn--success">Create Thread</a>
<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">
${drafts.map(p => {
${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 statusBadge = p.status === 'scheduled'
? '<span class="badge badge--scheduled">Scheduled</span>'
: '<span class="badge badge--draft">Draft</span>';
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="card card--draft">
return `<a href="${href}" class="${cardClass}">
<div class="card__badges">
${statusBadge}
<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>
@ -179,7 +224,7 @@ export class FolkThreadGallery extends HTMLElement {
</div>
</a>`;
}).join('')}
${threads.map(t => {
${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' });
@ -230,6 +275,8 @@ export class FolkThreadGallery extends HTMLElement {
}
.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;
@ -237,7 +284,37 @@ export class FolkThreadGallery extends HTMLElement {
}
.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;
@ -258,9 +335,21 @@ export class FolkThreadGallery extends HTMLElement {
<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();
});
});
}
}

View File

@ -3026,7 +3026,7 @@ routes.get("/threads", (c) => {
theme: "dark",
body: `<folk-thread-gallery space="${escapeHtml(space)}"></folk-thread-gallery>`,
styles: `<link rel="stylesheet" href="/modules/rsocials/socials.css">`,
scripts: `<script type="module" src="/modules/rsocials/folk-thread-gallery.js"></script>`,
scripts: `<script type="module" src="/modules/rsocials/folk-thread-gallery.js?v=2"></script>`,
}));
});
@ -3169,7 +3169,7 @@ routes.get("/landing", (c) => {
}));
});
// ── Default: rSocials hub with navigation ──
// ── Default: rSocials landing — posts gallery with draft/scheduled/published filter ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
@ -3182,44 +3182,19 @@ routes.get("/", (c) => {
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
styles: `<style>
.rs-hub{max-width:720px;margin:3rem auto;padding:0 1.5rem}
.rs-hub h1{font-size:1.8rem;margin-bottom:.5rem}
.rs-hub p{color:var(--rs-text-secondary,#aaa);margin-bottom:2rem}
.rs-nav{display:flex;flex-direction:column;gap:1rem}
.rs-nav a{display:flex;align-items:center;gap:1rem;padding:1.25rem 1.5rem;border-radius:12px;background:var(--rs-surface,#1e1e2e);border:1px solid var(--rs-border,#333);text-decoration:none;color:inherit;transition:border-color .15s,background .15s}
.rs-nav a:hover{border-color:var(--rs-accent,#14b8a6);background:var(--rs-surface-hover,#252538)}
.rs-nav .nav-icon{font-size:2rem;flex-shrink:0}
.rs-nav .nav-body h3{margin:0 0 .25rem;font-size:1.1rem}
.rs-nav .nav-body p{margin:0;font-size:.85rem;color:var(--rs-text-secondary,#aaa)}
styles: `<link rel="stylesheet" href="/modules/rsocials/socials.css">
<style>
.rs-subnav{max-width:900px;margin:1.25rem auto 0;padding:0 1rem;display:flex;gap:.5rem;flex-wrap:wrap;align-items:center;justify-content:flex-end}
.rs-subnav a{font-size:.8rem;padding:.45rem .9rem;border-radius:8px;border:1px solid var(--rs-input-border,#334155);background:var(--rs-bg-surface,#1e293b);color:var(--rs-text-secondary,#94a3b8);text-decoration:none;display:inline-flex;gap:.4rem;align-items:center}
.rs-subnav a:hover{border-color:#14b8a6;color:#5eead4}
</style>`,
body: `<div class="rs-hub">
<h1>rSocials</h1>
<p>Social media tools for your community</p>
<nav class="rs-nav">
<a href="${base}/campaigns">
<span class="nav-icon">📢</span>
<div class="nav-body">
<h3>Campaigns</h3>
<p>Plan and manage multi-platform social media campaigns</p>
</div>
</a>
<a href="${base}/threads">
<span class="nav-icon">🧵</span>
<div class="nav-body">
<h3>Posts & Threads</h3>
<p>Browse draft posts, compose threads, and schedule content with live preview</p>
</div>
</a>
<a href="${base}/newsletter">
<span class="nav-icon">📧</span>
<div class="nav-body">
<h3>Newsletter</h3>
<p>Draft newsletters, manage subscribers, and send campaigns</p>
</div>
</a>
</nav>
</div>`,
body: `<nav class="rs-subnav" aria-label="rSocials sections">
<a href="${base}/campaigns">📢 Campaigns</a>
<a href="${base}/campaign-flow">🧩 Campaign Canvas</a>
<a href="${base}/newsletter">📧 Newsletter</a>
</nav>
<folk-thread-gallery space="${escapeHtml(space)}"></folk-thread-gallery>`,
scripts: `<script type="module" src="/modules/rsocials/folk-thread-gallery.js?v=2"></script>`,
}));
});