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