merge(dev): tweet chain UX in post overlay
CI/CD / deploy (push) Successful in 2m47s Details

This commit is contained in:
Jeff Emmett 2026-04-18 13:15:06 -04:00
commit c7c43e4cab
2 changed files with 245 additions and 29 deletions

View File

@ -20,18 +20,25 @@ interface DraftPostCard {
status: string;
hashtags: string[];
threadId?: string;
threadPosts?: string[];
}
type StatusFilter = 'draft' | 'scheduled' | 'published';
const STATUS_STORAGE_KEY = 'rsocials:gallery:status-filter';
interface EditDraft {
content: string;
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[] = [];
@ -152,6 +159,7 @@ export class FolkThreadGallery extends HTMLElement {
status: post.status,
hashtags: post.hashtags || [],
threadId: post.threadId,
threadPosts: post.threadPosts ? [...post.threadPosts] : undefined,
});
}
}
@ -230,10 +238,13 @@ export class FolkThreadGallery extends HTMLElement {
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>
@ -308,6 +319,7 @@ export class FolkThreadGallery extends HTMLElement {
.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;
@ -467,6 +479,96 @@ export class FolkThreadGallery extends HTMLElement {
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">
@ -530,27 +632,36 @@ export class FolkThreadGallery extends HTMLElement {
let body = '';
let footer = '';
const limit = PLATFORM_LIMITS[post.platform.toLowerCase()] ?? null;
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) : '';
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" for="f-content">Content</label>
<textarea id="f-content" class="edit-textarea" data-field="content">${this.esc(d.content)}</textarea>
<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">
<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 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>
@ -563,8 +674,16 @@ export class FolkThreadGallery extends HTMLElement {
<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="overlay-content">${this.esc(post.content) || '<em style="color:#64748b">Empty post</em>'}</div>
<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>
@ -572,8 +691,8 @@ export class FolkThreadGallery extends HTMLElement {
<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 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
@ -594,6 +713,44 @@ export class FolkThreadGallery extends HTMLElement {
</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;
@ -613,10 +770,16 @@ export class FolkThreadGallery extends HTMLElement {
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;
@ -625,14 +788,23 @@ export class FolkThreadGallery extends HTMLElement {
(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 mount of this overlay)
const card = backdrop.querySelector('#overlay-card') as HTMLElement | null;
if (card && !card.dataset.animated) {
card.dataset.animated = '1';
// 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 (origin) {
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);
@ -649,9 +821,11 @@ export class FolkThreadGallery extends HTMLElement {
// ── 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;
@ -664,6 +838,7 @@ export class FolkThreadGallery extends HTMLElement {
this._editMode = false;
this._editDraft = null;
this._flipOrigin = null;
this._flipPlayed = false;
this.render();
}
@ -672,17 +847,21 @@ export class FolkThreadGallery extends HTMLElement {
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 = {
content: post.content,
tweets,
scheduledAt: post.scheduledAt,
hashtags: (post.hashtags || []).join(' '),
status: post.status as 'draft' | 'scheduled' | 'published',
};
this._editMode = true;
this.render();
// Focus content textarea
// Focus first tweet
requestAnimationFrame(() => {
const ta = this.shadowRoot?.getElementById('f-content') as HTMLTextAreaElement | null;
const ta = this.shadowRoot?.querySelector('[data-tweet-idx="0"]') as HTMLTextAreaElement | null;
ta?.focus();
});
}
@ -694,6 +873,36 @@ export class FolkThreadGallery extends HTMLElement {
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;
@ -717,12 +926,19 @@ export class FolkThreadGallery extends HTMLElement {
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) {
post.content = draft.content;
// 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;

View File

@ -3026,7 +3026,7 @@ routes.get("/threads", (c) => {
theme: "dark",
body: `<folk-thread-gallery space="${escapeHtml(space)}"></folk-thread-gallery>`,
styles: `<link rel="stylesheet" href="/modules/rsocials/socials.css">`,
scripts: `<script type="module" src="/modules/rsocials/folk-thread-gallery.js?v=4"></script>`,
scripts: `<script type="module" src="/modules/rsocials/folk-thread-gallery.js?v=5"></script>`,
}));
});
@ -3181,7 +3181,7 @@ routes.get("/", (c) => {
theme: "dark",
styles: `<link rel="stylesheet" href="/modules/rsocials/socials.css">`,
body: `<folk-thread-gallery space="${escapeHtml(space)}"></folk-thread-gallery>`,
scripts: `<script type="module" src="/modules/rsocials/folk-thread-gallery.js?v=4"></script>`,
scripts: `<script type="module" src="/modules/rsocials/folk-thread-gallery.js?v=5"></script>`,
}));
});