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:
parent
4eba2cb163
commit
91837fd1d4
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue