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

974 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* <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[];
threadId?: string;
threadPosts?: string[];
}
type StatusFilter = 'draft' | 'scheduled' | 'published';
const STATUS_STORAGE_KEY = 'rsocials:gallery:status-filter';
interface EditDraft {
tweets: string[]; // thread-style: 1 element for single post, N for chains
scheduledAt: string;
hashtags: string;
status: 'draft' | 'scheduled' | 'published';
}
// Per-platform character limits (null = no practical limit shown in UI).
const PLATFORM_LIMITS: Record<string, number | null> = {
x: 280, twitter: 280, bluesky: 300, threads: 500,
linkedin: 3000, instagram: 2200, youtube: 5000, newsletter: null,
};
export class FolkThreadGallery extends HTMLElement {
private _space = 'demo';
private _threads: ThreadData[] = [];
private _allPosts: DraftPostCard[] = [];
private _offlineUnsub: (() => void) | null = null;
private _subscribedDocIds: string[] = [];
private _isDemoFallback = false;
private _statusFilter: StatusFilter = 'draft';
private _expandedPostId: string | null = null;
private _editMode = false;
private _editDraft: EditDraft | null = null;
private _savingIndicator = '';
private _boundKeyDown: ((e: KeyboardEvent) => void) | null = null;
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') {
this._statusFilter = saved;
}
} catch { /* ignore */ }
this._boundKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && this._expandedPostId) {
e.preventDefault();
if (this._editMode) {
this.exitEditMode();
} else {
this.closeOverlay();
}
}
};
document.addEventListener('keydown', this._boundKeyDown);
this.render();
this.subscribeOffline();
}
disconnectedCallback() {
this._offlineUnsub?.();
this._offlineUnsub = null;
const runtime = (window as any).__rspaceOfflineRuntime;
if (runtime) {
for (const id of this._subscribedDocIds) runtime.unsubscribe(id);
}
this._subscribedDocIds = [];
if (this._boundKeyDown) document.removeEventListener('keydown', this._boundKeyDown);
this._boundKeyDown = 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._subscribedDocIds.push(docId);
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 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 || []) {
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,
threadPosts: post.threadPosts ? [...post.threadPosts] : undefined,
});
}
}
}
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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 threads = this._threads;
const filter = this._statusFilter;
// Filter + sort posts by status + recency
const filteredPosts = this._allPosts
.filter(p => 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 = {
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('draft', 'Drafts')}
${chip('scheduled', 'Scheduled')}
${chip('published', 'Published')}
</div>`;
// Threads render alongside published posts (they have no draft state).
const threadsVisible = filter === 'published' ? threads : [];
const threadCardsHTML = filteredPosts.length === 0 && threadsVisible.length === 0
? `<div class="empty">
<p>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">
${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 statusClass = `badge badge--${p.status}`;
const statusLabel = p.status.charAt(0).toUpperCase() + p.status.slice(1);
const cardClass = `card card--${p.status}`;
const tweetCount = p.threadPosts?.length ?? 0;
const threadBadge = tweetCount > 1 ? `<span class="badge badge--thread">🧵 ${tweetCount}</span>` : '';
return `<button type="button" class="${cardClass}" data-post-id="${this.esc(p.id)}">
<div class="card__badges">
<span class="${statusClass}">${statusLabel}</span>
<span class="badge badge--campaign">${this.esc(p.campaignTitle)}</span>
${threadBadge}
</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>
</button>`;
}).join('')}
${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' });
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 { opacity: 0.85; }
.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;
font: inherit; text-align: left; cursor: pointer;
width: 100%;
}
.card:hover { border-color: var(--rs-primary); transform: translateY(-2px); }
.card:focus-visible { outline: 2px solid #14b8a6; outline-offset: 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;
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--published { background: rgba(34,197,94,0.15); color: #22c55e; }
.badge--campaign { background: rgba(99,102,241,0.15); color: #a5b4fc; }
.badge--thread { background: rgba(20,184,166,0.15); color: #5eead4; }
.filter-bar {
display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1.5rem;
justify-content: center;
}
.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;
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; }
/* ── Post detail overlay ── */
.overlay-backdrop {
position: fixed; inset: 0; z-index: 1000;
background: rgba(8, 12, 24, 0.6);
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center;
padding: 2rem; animation: fadeIn 0.15s ease-out;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.overlay-card {
background: var(--rs-bg-surface, #1e293b);
border: 1px solid var(--rs-input-border, #334155);
border-radius: 14px;
box-shadow: 0 24px 80px -20px rgba(0, 0, 0, 0.6);
width: min(720px, 100%); max-height: calc(100vh - 4rem);
display: flex; flex-direction: column; overflow: hidden;
transform-origin: center center;
}
.overlay-header {
display: flex; align-items: center; gap: 0.75rem;
padding: 1rem 1.25rem; border-bottom: 1px solid var(--rs-input-border, #334155);
}
.overlay-platform {
font-size: 1.25rem; line-height: 1;
width: 2.25rem; height: 2.25rem; border-radius: 8px;
background: rgba(20, 184, 166, 0.12);
display: flex; align-items: center; justify-content: center;
}
.overlay-headline {
flex: 1; display: flex; flex-direction: column; gap: 0.15rem; min-width: 0;
}
.overlay-title {
font-size: 1rem; font-weight: 700; color: var(--rs-text-primary, #f1f5f9);
line-height: 1.25; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.overlay-sub {
display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap;
font-size: 0.75rem; color: var(--rs-text-muted, #64748b);
}
.overlay-close {
background: transparent; border: 1px solid var(--rs-input-border, #334155);
color: var(--rs-text-secondary, #94a3b8);
width: 2rem; height: 2rem; border-radius: 8px;
font-size: 1.1rem; line-height: 1; cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: background 0.12s, color 0.12s;
}
.overlay-close:hover { background: rgba(239,68,68,0.1); color: #fca5a5; border-color: #ef4444; }
.overlay-body {
padding: 1.5rem 1.25rem;
overflow-y: auto;
display: flex; flex-direction: column; gap: 1rem;
}
.overlay-content {
font-size: 1rem; line-height: 1.6;
color: var(--rs-text-primary, #f1f5f9);
white-space: pre-wrap; word-wrap: break-word;
}
.overlay-meta-grid {
display: grid; grid-template-columns: auto 1fr; gap: 0.5rem 1rem;
font-size: 0.82rem;
}
.overlay-meta-label {
color: var(--rs-text-muted, #64748b);
text-transform: uppercase; letter-spacing: 0.05em; font-size: 0.7rem; font-weight: 600;
padding-top: 0.25rem;
}
.overlay-meta-value { color: var(--rs-text-secondary, #94a3b8); }
.overlay-hashtags { display: flex; gap: 0.4rem; flex-wrap: wrap; }
.overlay-hashtags span {
background: rgba(99,102,241,0.15); color: #a5b4fc;
padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.72rem;
}
.overlay-footer {
display: flex; justify-content: flex-end; gap: 0.5rem;
padding: 0.85rem 1.25rem; border-top: 1px solid var(--rs-input-border, #334155);
}
.overlay-btn {
padding: 0.5rem 1rem; border-radius: 8px;
border: 1px solid var(--rs-input-border, #334155);
background: transparent; color: var(--rs-text-primary, #e2e8f0);
font-size: 0.85rem; font-weight: 600; cursor: pointer;
font-family: inherit;
transition: background 0.12s, border-color 0.12s;
}
.overlay-btn:hover { background: rgba(255,255,255,0.05); }
.overlay-btn--primary {
background: linear-gradient(135deg,#14b8a6,#0d9488); color: #fff; border-color: #0d9488;
}
.overlay-btn--primary:hover { filter: brightness(1.1); }
.overlay-btn--danger { border-color: #ef4444; color: #fca5a5; }
.overlay-btn--danger:hover { background: rgba(239,68,68,0.1); }
/* Edit-mode controls */
.edit-field { display: flex; flex-direction: column; gap: 0.3rem; }
.edit-label {
font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;
color: var(--rs-text-muted, #64748b);
}
.edit-input, .edit-textarea, .edit-select {
background: var(--rs-bg-surface-raised, #0f172a);
border: 1px solid var(--rs-input-border, #334155);
border-radius: 8px; color: var(--rs-text-primary, #f1f5f9);
padding: 0.6rem 0.75rem; font-size: 0.9rem; font-family: inherit;
}
.edit-input:focus, .edit-textarea:focus, .edit-select:focus {
outline: none; border-color: #14b8a6;
box-shadow: 0 0 0 3px rgba(20,184,166,0.15);
}
.edit-textarea {
min-height: 120px; resize: vertical; line-height: 1.6;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.9rem;
}
.edit-saving { font-size: 0.75rem; color: #94a3b8; margin-right: auto; align-self: center; }
.edit-field-row { display: grid; grid-template-columns: 1fr 1.4fr; gap: 0.75rem; }
@media (max-width: 480px) { .edit-field-row { grid-template-columns: 1fr; } }
/* ── Tweet chain (downward) ── */
.tweet-chain { display: flex; flex-direction: column; gap: 0; }
.tweet-chain--view { margin-bottom: 0.5rem; }
.tweet-row {
display: grid; grid-template-columns: 32px 1fr; gap: 0.75rem;
padding-bottom: 0.75rem;
}
.tweet-spine {
display: flex; flex-direction: column; align-items: center;
}
.tweet-dot {
width: 32px; height: 32px; border-radius: 50%;
background: rgba(20, 184, 166, 0.15);
display: flex; align-items: center; justify-content: center;
font-size: 0.95rem; line-height: 1; flex-shrink: 0;
}
.tweet-line {
flex: 1; width: 2px; background: var(--rs-input-border, #334155);
margin-top: 4px; border-radius: 1px;
}
.tweet-body {
display: flex; flex-direction: column; gap: 0.35rem;
padding-bottom: 0.25rem; min-width: 0;
}
.tweet-head {
display: flex; align-items: center; gap: 0.6rem;
font-size: 0.7rem;
}
.tweet-num {
color: var(--rs-text-muted, #64748b);
font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;
}
.tweet-count {
color: #94a3b8; font-variant-numeric: tabular-nums;
margin-left: auto;
}
.tweet-count--over { color: #ef4444; font-weight: 700; }
.tweet-content {
font-size: 0.95rem; line-height: 1.55;
color: var(--rs-text-primary, #f1f5f9);
white-space: pre-wrap; word-wrap: break-word;
background: rgba(255,255,255,0.025);
padding: 0.75rem 0.9rem; border-radius: 10px;
border: 1px solid rgba(255,255,255,0.04);
}
.tweet-textarea {
background: var(--rs-bg-surface-raised, #0f172a);
border: 1px solid var(--rs-input-border, #334155);
border-radius: 10px;
color: var(--rs-text-primary, #f1f5f9);
padding: 0.7rem 0.85rem; font-size: 0.93rem; font-family: inherit;
line-height: 1.55; resize: vertical; min-height: 72px;
}
.tweet-textarea:focus {
outline: none; border-color: #14b8a6;
box-shadow: 0 0 0 3px rgba(20,184,166,0.15);
}
.tweet-remove {
background: transparent; border: none;
color: #94a3b8; cursor: pointer;
width: 22px; height: 22px; border-radius: 4px;
line-height: 1; font-size: 1rem;
display: inline-flex; align-items: center; justify-content: center;
transition: background 0.1s, color 0.1s;
}
.tweet-remove:hover { background: rgba(239,68,68,0.12); color: #fca5a5; }
.tweet-add {
display: flex; align-items: center; gap: 0.6rem;
background: transparent;
border: 1.5px dashed var(--rs-input-border, #334155);
color: var(--rs-text-muted, #64748b);
border-radius: 10px;
padding: 0.6rem 0.9rem; cursor: pointer;
font-family: inherit; font-size: 0.85rem; font-weight: 600;
margin-left: 44px; /* align with tweet body */
transition: background 0.12s, border-color 0.12s, color 0.12s;
}
.tweet-add:hover {
background: rgba(20,184,166,0.08);
border-color: #14b8a6; color: #5eead4;
}
.tweet-add__plus {
width: 22px; height: 22px; border-radius: 50%;
background: rgba(20, 184, 166, 0.2); color: #5eead4;
display: inline-flex; align-items: center; justify-content: center;
font-size: 1rem; line-height: 1; font-weight: 700;
}
</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>
${filterBar}
${threadCardsHTML}
</div>
${this.renderOverlay()}
`;
// 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();
});
});
// Card click → expand
this.shadowRoot.querySelectorAll('button.card[data-post-id]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
const id = (btn as HTMLElement).dataset.postId;
if (id) this.expandPost(id, btn as HTMLElement);
});
});
// Overlay wiring
this.wireOverlay();
}
// ── Overlay rendering ──
private renderOverlay(): string {
if (!this._expandedPostId) return '';
const post = this._allPosts.find(p => p.id === this._expandedPostId);
if (!post) return '';
const platformLabel = post.platform.charAt(0).toUpperCase() + post.platform.slice(1);
const statusLabel = post.status.charAt(0).toUpperCase() + post.status.slice(1);
const schedDate = post.scheduledAt
? new Date(post.scheduledAt).toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' })
: '—';
const header = `<div class="overlay-header">
<div class="overlay-platform">${this.platformIcon(post.platform)}</div>
<div class="overlay-headline">
<div class="overlay-title">${this.esc(platformLabel)} Post</div>
<div class="overlay-sub">
<span class="badge badge--${post.status}">${statusLabel}</span>
<span class="badge badge--campaign">${this.esc(post.campaignTitle)}</span>
</div>
</div>
<button type="button" class="overlay-close" data-action="close" aria-label="Close">×</button>
</div>`;
let body = '';
let footer = '';
const limit = PLATFORM_LIMITS[post.platform.toLowerCase()] ?? null;
if (this._editMode && this._editDraft) {
const d = this._editDraft;
const dtLocal = d.scheduledAt ? toDatetimeLocal(d.scheduledAt) : '';
const tweetChainHtml = d.tweets.map((t, i) => this.renderEditTweet(t, i, d.tweets.length, limit, post.platform)).join('');
body = `<div class="overlay-body">
<div class="edit-field">
<label class="edit-label">Tweet chain</label>
<div class="tweet-chain">
${tweetChainHtml}
<button type="button" class="tweet-add" data-action="add-tweet" aria-label="Add tweet">
<span class="tweet-add__plus">+</span>
<span class="tweet-add__label">Add tweet to chain</span>
</button>
</div>
</div>
<div class="edit-field-row">
<div class="edit-field">
<label class="edit-label" for="f-status">Status</label>
<select id="f-status" class="edit-select" data-field="status">
<option value="draft"${d.status === 'draft' ? ' selected' : ''}>Draft</option>
<option value="scheduled"${d.status === 'scheduled' ? ' selected' : ''}>Scheduled</option>
<option value="published"${d.status === 'published' ? ' selected' : ''}>Published</option>
</select>
</div>
<div class="edit-field">
<label class="edit-label" for="f-sched">Scheduled at</label>
<input id="f-sched" type="datetime-local" class="edit-input" data-field="scheduledAt" value="${this.esc(dtLocal)}" />
</div>
</div>
<div class="edit-field">
<label class="edit-label" for="f-tags">Hashtags (space separated)</label>
<input id="f-tags" class="edit-input" data-field="hashtags" value="${this.esc(d.hashtags)}" placeholder="#launch #campaign" />
</div>
</div>`;
footer = `<div class="overlay-footer">
<span class="edit-saving">${this.esc(this._savingIndicator)}</span>
<button type="button" class="overlay-btn" data-action="cancel-edit">Cancel</button>
<button type="button" class="overlay-btn overlay-btn--primary" data-action="save">Save</button>
</div>`;
} else {
const viewTweets = post.threadPosts && post.threadPosts.length > 0
? post.threadPosts
: [post.content || ''];
const tweetChainView = viewTweets.map((t, i) => this.renderViewTweet(t, i, viewTweets.length, limit, post.platform)).join('');
const isThread = viewTweets.length > 1;
body = `<div class="overlay-body">
<div class="tweet-chain tweet-chain--view">
${tweetChainView}
</div>
<div class="overlay-meta-grid">
<div class="overlay-meta-label">Schedule</div>
<div class="overlay-meta-value">${this.esc(schedDate)}</div>
<div class="overlay-meta-label">Hashtags</div>
<div class="overlay-hashtags">${post.hashtags.length ? post.hashtags.map(h => `<span>${this.esc(h)}</span>`).join('') : '<span style="background:transparent;color:#64748b">—</span>'}</div>
<div class="overlay-meta-label">Campaign</div>
<div class="overlay-meta-value">${this.esc(post.campaignTitle)}</div>
<div class="overlay-meta-label">${isThread ? 'Tweets' : 'Characters'}</div>
<div class="overlay-meta-value">${isThread ? `${viewTweets.length} tweets · ${viewTweets.reduce((s, t) => s + t.length, 0)} chars total` : String(viewTweets[0].length)}</div>
</div>
</div>`;
const openHref = post.threadId
? `${this.basePath}thread-editor/${this.esc(post.threadId)}/edit`
: `${this.basePath}campaign`;
footer = `<div class="overlay-footer">
<a href="${openHref}" class="overlay-btn">Open in Campaign ↗</a>
<button type="button" class="overlay-btn overlay-btn--primary" data-action="edit">Edit</button>
</div>`;
}
return `<div class="overlay-backdrop" data-action="backdrop">
<div class="overlay-card" id="overlay-card" role="dialog" aria-modal="true" aria-label="Post details">
${header}
${body}
${footer}
</div>
</div>`;
}
private renderViewTweet(content: string, idx: number, total: number, limit: number | null, platform: string): string {
const charsLeft = limit !== null ? limit - content.length : null;
const overLimit = charsLeft !== null && charsLeft < 0;
const empty = content.trim().length === 0;
return `<article class="tweet-row tweet-row--view">
<div class="tweet-spine">
<div class="tweet-dot">${this.platformIcon(platform)}</div>
${idx < total - 1 ? '<div class="tweet-line"></div>' : ''}
</div>
<div class="tweet-body">
<div class="tweet-head">
<span class="tweet-num">${total > 1 ? `${idx + 1}/${total}` : 'Tweet'}</span>
${limit !== null ? `<span class="tweet-count${overLimit ? ' tweet-count--over' : ''}">${charsLeft}</span>` : ''}
</div>
<div class="tweet-content">${empty ? '<em style="color:#64748b">Empty</em>' : this.esc(content)}</div>
</div>
</article>`;
}
private renderEditTweet(content: string, idx: number, total: number, limit: number | null, platform: string): string {
const charsLeft = limit !== null ? limit - content.length : null;
const overLimit = charsLeft !== null && charsLeft < 0;
return `<article class="tweet-row tweet-row--edit">
<div class="tweet-spine">
<div class="tweet-dot">${this.platformIcon(platform)}</div>
${idx < total - 1 ? '<div class="tweet-line"></div>' : ''}
</div>
<div class="tweet-body">
<div class="tweet-head">
<span class="tweet-num">${total > 1 ? `${idx + 1}/${total}` : 'Tweet'}</span>
${limit !== null ? `<span class="tweet-count${overLimit ? ' tweet-count--over' : ''}">${charsLeft}</span>` : ''}
${total > 1 ? `<button type="button" class="tweet-remove" data-action="remove-tweet" data-idx="${idx}" aria-label="Remove tweet ${idx + 1}" title="Remove tweet">×</button>` : ''}
</div>
<textarea class="tweet-textarea" data-tweet-idx="${idx}" rows="3">${this.esc(content)}</textarea>
</div>
</article>`;
}
private wireOverlay(): void {
if (!this.shadowRoot) return;
const backdrop = this.shadowRoot.querySelector('.overlay-backdrop') as HTMLElement | null;
if (!backdrop) return;
backdrop.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
if (target.dataset.action === 'backdrop') {
if (this._editMode) this.exitEditMode();
else this.closeOverlay();
return;
}
const actionEl = target.closest<HTMLElement>('[data-action]');
if (!actionEl) return;
const action = actionEl.dataset.action;
if (action === 'close') this.closeOverlay();
else if (action === 'edit') this.enterEditMode();
else if (action === 'cancel-edit') this.exitEditMode();
else if (action === 'save') this.saveEdit();
else if (action === 'add-tweet') this.addTweet();
else if (action === 'remove-tweet') {
const idx = parseInt(actionEl.dataset.idx || '', 10);
if (!isNaN(idx)) this.removeTweet(idx);
}
});
// Bind input listeners in edit mode so the draft stays fresh
if (this._editMode) {
// Meta fields (status/sched/hashtags)
backdrop.querySelectorAll<HTMLElement>('[data-field]').forEach(el => {
el.addEventListener('input', (e) => {
const input = e.currentTarget as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
const field = input.dataset.field;
if (!field || !this._editDraft) return;
(this._editDraft as any)[field] = input.value;
});
});
// Tweet textareas — update draft + live character count without full re-render
backdrop.querySelectorAll<HTMLTextAreaElement>('textarea[data-tweet-idx]').forEach(ta => {
ta.addEventListener('input', () => {
const idx = parseInt(ta.dataset.tweetIdx || '', 10);
if (isNaN(idx) || !this._editDraft) return;
this._editDraft.tweets[idx] = ta.value;
this.updateTweetCounter(ta, ta.value);
});
});
}
// FLIP animation entry — only on first render per open
if (!this._flipPlayed) {
this._flipPlayed = true;
const card = backdrop.querySelector('#overlay-card') as HTMLElement | null;
const origin = this._flipOrigin;
if (card && origin) {
const target = card.getBoundingClientRect();
const dx = origin.left + origin.width / 2 - (target.left + target.width / 2);
const dy = origin.top + origin.height / 2 - (target.top + target.height / 2);
const sx = origin.width / target.width;
const sy = origin.height / target.height;
card.animate([
{ transform: `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`, opacity: 0.6 },
{ transform: 'translate(0, 0) scale(1, 1)', opacity: 1 },
], { duration: 220, easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)' });
}
}
}
// ── Overlay state transitions ──
private _flipOrigin: DOMRect | null = null;
private _flipPlayed = false;
private expandPost(id: string, sourceEl?: HTMLElement): void {
this._flipOrigin = sourceEl?.getBoundingClientRect() ?? null;
this._flipPlayed = false;
this._expandedPostId = id;
this._editMode = false;
this._editDraft = null;
this._savingIndicator = '';
this.render();
}
private closeOverlay(): void {
this._expandedPostId = null;
this._editMode = false;
this._editDraft = null;
this._flipOrigin = null;
this._flipPlayed = false;
this.render();
}
private enterEditMode(): void {
if (!this._expandedPostId) return;
if (this._isDemoFallback) return; // no runtime = read-only
const post = this._allPosts.find(p => p.id === this._expandedPostId);
if (!post) return;
// Seed tweets from post.threadPosts if present, else single-tweet array from content.
const tweets = post.threadPosts && post.threadPosts.length > 0
? [...post.threadPosts]
: [post.content || ''];
this._editDraft = {
tweets,
scheduledAt: post.scheduledAt,
hashtags: (post.hashtags || []).join(' '),
status: post.status as 'draft' | 'scheduled' | 'published',
};
this._editMode = true;
this.render();
// Focus first tweet
requestAnimationFrame(() => {
const ta = this.shadowRoot?.querySelector('[data-tweet-idx="0"]') as HTMLTextAreaElement | null;
ta?.focus();
});
}
private exitEditMode(): void {
this._editMode = false;
this._editDraft = null;
this._savingIndicator = '';
this.render();
}
private addTweet(): void {
if (!this._editDraft) return;
this._editDraft.tweets.push('');
this.render();
// Focus the new tweet
requestAnimationFrame(() => {
const tweets = this.shadowRoot?.querySelectorAll<HTMLTextAreaElement>('textarea[data-tweet-idx]');
tweets?.[tweets.length - 1]?.focus();
});
}
private removeTweet(idx: number): void {
if (!this._editDraft || this._editDraft.tweets.length <= 1) return;
this._editDraft.tweets.splice(idx, 1);
this.render();
}
private updateTweetCounter(ta: HTMLTextAreaElement, value: string): void {
const post = this._allPosts.find(p => p.id === this._expandedPostId);
if (!post) return;
const limit = PLATFORM_LIMITS[post.platform.toLowerCase()] ?? null;
if (limit === null) return;
const row = ta.closest('.tweet-row');
const counter = row?.querySelector('.tweet-count') as HTMLElement | null;
if (!counter) return;
const left = limit - value.length;
counter.textContent = String(left);
counter.classList.toggle('tweet-count--over', left < 0);
}
private async saveEdit(): Promise<void> {
if (!this._editDraft || !this._expandedPostId) return;
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime?.isInitialized) {
this._savingIndicator = 'Offline runtime unavailable';
this.render();
return;
}
const draft = this._editDraft;
const postId = this._expandedPostId;
const hashtags = draft.hashtags
.split(/[\s,]+/)
.map(s => s.trim())
.filter(Boolean)
.map(s => s.startsWith('#') ? s : `#${s}`);
// datetime-local string → ISO
const scheduledAtIso = draft.scheduledAt ? new Date(draft.scheduledAt).toISOString() : '';
this._savingIndicator = 'Saving…';
this.render();
try {
const docId = socialsDocId(this._space) as DocumentId;
// Normalize: strip empty trailing tweets; keep at least one.
const tweets = draft.tweets.map(t => t.trim()).filter((t, i, arr) => t.length > 0 || i === 0);
if (tweets.length === 0) tweets.push('');
const isThread = tweets.length > 1;
await runtime.changeDoc(docId, 'edit post', (d: SocialsDoc) => {
if (!d.campaigns) return;
for (const campaign of Object.values(d.campaigns)) {
const post = (campaign.posts || []).find((p: CampaignPost) => p.id === postId);
if (post) {
// Single-tweet → use content; thread → use threadPosts (keep content in sync w/ tweet 1)
post.content = tweets[0];
post.threadPosts = isThread ? tweets : undefined;
post.status = draft.status;
post.scheduledAt = scheduledAtIso;
post.hashtags = hashtags;
return;
}
}
});
this._savingIndicator = 'Saved';
this._editMode = false;
this._editDraft = null;
this.render();
setTimeout(() => { this._savingIndicator = ''; this.render(); }, 1500);
} catch (err) {
console.warn('[folk-thread-gallery] save failed', err);
this._savingIndicator = 'Save failed';
this.render();
}
}
}
function toDatetimeLocal(iso: string): string {
try {
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
} catch {
return '';
}
}
customElements.define('folk-thread-gallery', FolkThreadGallery);