merge(dev): rsocials post detail overlay
CI/CD / deploy (push) Successful in 2m40s Details

This commit is contained in:
Jeff Emmett 2026-04-18 12:24:36 -04:00
commit a3f3e67cdb
2 changed files with 411 additions and 8 deletions

View File

@ -25,6 +25,13 @@ interface DraftPostCard {
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[] = [];
@ -33,6 +40,11 @@ export class FolkThreadGallery extends HTMLElement {
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']; }
@ -46,6 +58,17 @@ export class FolkThreadGallery extends HTMLElement {
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();
}
@ -58,6 +81,8 @@ export class FolkThreadGallery extends HTMLElement {
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) {
@ -192,7 +217,7 @@ export class FolkThreadGallery extends HTMLElement {
const threadCardsHTML = filteredPosts.length === 0 && threadsVisible.length === 0
? `<div class="empty">
<p>${filter === 'all' ? 'No posts or threads yet.' : `No ${filter} posts.`} Create your first post or thread.</p>
<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>
@ -205,10 +230,7 @@ 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 href = p.threadId
? `${this.basePath}thread-editor/${this.esc(p.threadId)}/edit`
: `${this.basePath}campaign`;
return `<a href="${href}" class="${cardClass}">
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>
@ -219,7 +241,7 @@ export class FolkThreadGallery extends HTMLElement {
${p.hashtags.length ? `<span>${p.hashtags.slice(0, 3).join(' ')}</span>` : ''}
${schedDate ? `<span>${schedDate}</span>` : ''}
</div>
</a>`;
</button>`;
}).join('')}
${threadsVisible.map(t => {
const initial = (t.name || '?').charAt(0).toUpperCase();
@ -269,8 +291,11 @@ export class FolkThreadGallery extends HTMLElement {
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; }
@ -327,6 +352,121 @@ export class FolkThreadGallery extends HTMLElement {
}
.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">
@ -336,6 +476,7 @@ export class FolkThreadGallery extends HTMLElement {
${filterBar}
${threadCardsHTML}
</div>
${this.renderOverlay()}
`;
// Attach chip click handlers after render
@ -348,6 +489,268 @@ export class FolkThreadGallery extends HTMLElement {
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 '';
}
}

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=3"></script>`,
scripts: `<script type="module" src="/modules/rsocials/folk-thread-gallery.js?v=4"></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=3"></script>`,
scripts: `<script type="module" src="/modules/rsocials/folk-thread-gallery.js?v=4"></script>`,
}));
});