From 729570c48b7a95c7585851b1b2c8baab1e532a29 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 18 Apr 2026 12:24:33 -0400 Subject: [PATCH] feat(rsocials): progressive-zoom post detail overlay with inline edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking a post card expands it into a FLIP-animated detail overlay instead of navigating away. Overlay has two modes: View mode — full post content, schedule, hashtags, campaign, char count. Actions: Open in Campaign (external link), Edit. Edit mode — textarea for content, status picker (Draft/Scheduled/ Published), datetime-local for scheduled date, space-separated hashtag input. Saves via offline runtime changeDoc so the post list updates live everywhere else the doc is subscribed. Hashtag strings are auto-prefixed with # if missing. ESC cancels edit; ESC again (or backdrop click) closes the overlay. Backdrop blur + smooth translate+scale entry from the clicked card's rect. Threads still use the dedicated thread-editor since they have multi-tweet rich editing. Card anchors become buttons with data-post-id; cursor/focus-visible styling preserved. Cache bump folk-thread-gallery to v=4. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/folk-thread-gallery.ts | 415 +++++++++++++++++- modules/rsocials/mod.ts | 4 +- 2 files changed, 411 insertions(+), 8 deletions(-) diff --git a/modules/rsocials/components/folk-thread-gallery.ts b/modules/rsocials/components/folk-thread-gallery.ts index ae5dd7a9..e78fc9f9 100644 --- a/modules/rsocials/components/folk-thread-gallery.ts +++ b/modules/rsocials/components/folk-thread-gallery.ts @@ -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 ? `
-

${filter === 'all' ? 'No posts or threads yet.' : `No ${filter} posts.`} Create your first post or thread.

+

No ${filter} posts. Create your first post or thread.

Create Thread Open Campaigns @@ -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 ` + return ``; }).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; }