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

758 lines
29 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;
}
type StatusFilter = 'draft' | 'scheduled' | 'published';
const STATUS_STORAGE_KEY = 'rsocials:gallery:status-filter';
interface EditDraft {
content: string;
scheduledAt: string;
hashtags: string;
status: 'draft' | 'scheduled' | 'published';
}
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,
});
}
}
}
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}`;
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>
</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; }
.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; }
</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 = '';
if (this._editMode && this._editDraft) {
const d = this._editDraft;
// datetime-local needs YYYY-MM-DDTHH:mm in local time
const dtLocal = d.scheduledAt ? toDatetimeLocal(d.scheduledAt) : '';
body = `<div class="overlay-body">
<div class="edit-field">
<label class="edit-label" for="f-content">Content</label>
<textarea id="f-content" class="edit-textarea" data-field="content">${this.esc(d.content)}</textarea>
</div>
<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 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 {
body = `<div class="overlay-body">
<div class="overlay-content">${this.esc(post.content) || '<em style="color:#64748b">Empty post</em>'}</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">Characters</div>
<div class="overlay-meta-value">${post.content.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 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();
});
// Bind input listeners in edit mode so the draft stays fresh
if (this._editMode) {
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;
});
});
}
// FLIP animation entry (only on first mount of this overlay)
const card = backdrop.querySelector('#overlay-card') as HTMLElement | null;
if (card && !card.dataset.animated) {
card.dataset.animated = '1';
const origin = this._flipOrigin;
if (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 expandPost(id: string, sourceEl?: HTMLElement): void {
this._flipOrigin = sourceEl?.getBoundingClientRect() ?? null;
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.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;
this._editDraft = {
content: post.content,
scheduledAt: post.scheduledAt,
hashtags: (post.hashtags || []).join(' '),
status: post.status as 'draft' | 'scheduled' | 'published',
};
this._editMode = true;
this.render();
// Focus content textarea
requestAnimationFrame(() => {
const ta = this.shadowRoot?.getElementById('f-content') as HTMLTextAreaElement | null;
ta?.focus();
});
}
private exitEditMode(): void {
this._editMode = false;
this._editDraft = null;
this._savingIndicator = '';
this.render();
}
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;
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) {
post.content = draft.content;
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);