merge(dev): rsocials timeline view
CI/CD / deploy (push) Successful in 4m14s Details

This commit is contained in:
Jeff Emmett 2026-04-18 14:26:31 -04:00
commit 057288209d
2 changed files with 488 additions and 9 deletions

View File

@ -24,7 +24,48 @@ interface DraftPostCard {
}
type StatusFilter = 'draft' | 'scheduled' | 'published';
type ViewMode = 'grid' | 'timeline';
const STATUS_STORAGE_KEY = 'rsocials:gallery:status-filter';
const VIEW_STORAGE_KEY = 'rsocials:gallery:view-mode';
const TIMELINE_ZOOM_STORAGE_KEY = 'rsocials:gallery:timeline-zoom';
// Timeline constants
const TIMELINE_BASE_PX_PER_HOUR = 30; // baseline; multiplied by zoom
const TIMELINE_ZOOM_MIN = 0.15; // ~week+ visible on screen
const TIMELINE_ZOOM_MAX = 4; // single-hour detail
const TIMELINE_LANE_HEIGHT = 84;
const TIMELINE_CARD_W = 200;
const TIMELINE_CARD_H = 72;
const TIMELINE_HEADER_H = 56;
const TIMELINE_LEFT_GUTTER = 110;
const TIMELINE_PLATFORMS = ['x', 'linkedin', 'instagram', 'threads', 'bluesky', 'youtube', 'newsletter', 'other'] as const;
const PLATFORM_LANE_LABELS: Record<string, string> = {
x: '𝕏 X',
linkedin: '💼 LinkedIn',
instagram: '📷 Instagram',
threads: '🧵 Threads',
bluesky: '🦋 Bluesky',
youtube: '📹 YouTube',
newsletter: '📧 Newsletter',
other: '📱 Other',
};
function platformLaneKey(platform: string): string {
const p = platform.toLowerCase();
if (p === 'twitter') return 'x';
if ((TIMELINE_PLATFORMS as readonly string[]).includes(p)) return p;
return 'other';
}
/** Pick the tick spacing (in hours) that keeps labels readable at a given zoom. */
function tickHoursForZoom(pxPerHour: number): number {
if (pxPerHour >= 80) return 1;
if (pxPerHour >= 40) return 3;
if (pxPerHour >= 20) return 6;
if (pxPerHour >= 10) return 12;
return 24; // day-only at very zoomed-out
}
interface EditDraft {
tweets: string[]; // thread-style: 1 element for single post, N for chains
@ -52,18 +93,28 @@ export class FolkThreadGallery extends HTMLElement {
private _editDraft: EditDraft | null = null;
private _savingIndicator = '';
private _boundKeyDown: ((e: KeyboardEvent) => void) | null = null;
private _viewMode: ViewMode = 'grid';
private _timelineZoom = 1; // multiplier on TIMELINE_BASE_PX_PER_HOUR
private _timelineScrollLeft = 0; // preserved across re-renders
private _pendingTimelineScrollToNow = false;
static get observedAttributes() { return ['space']; }
connectedCallback() {
if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
this._space = this.getAttribute('space') || 'demo';
// Restore last status filter
// Restore last status filter + view mode + timeline zoom
try {
const saved = localStorage.getItem(STATUS_STORAGE_KEY);
if (saved === 'draft' || saved === 'scheduled' || saved === 'published') {
this._statusFilter = saved;
}
const view = localStorage.getItem(VIEW_STORAGE_KEY);
if (view === 'grid' || view === 'timeline') this._viewMode = view;
const zoom = parseFloat(localStorage.getItem(TIMELINE_ZOOM_STORAGE_KEY) || '');
if (isFinite(zoom) && zoom >= TIMELINE_ZOOM_MIN && zoom <= TIMELINE_ZOOM_MAX) {
this._timelineZoom = zoom;
}
} catch { /* ignore */ }
this._boundKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && this._expandedPostId) {
@ -220,17 +271,24 @@ export class FolkThreadGallery extends HTMLElement {
${chip('published', 'Published')}
</div>`;
const viewToggle = `<div class="view-toggle" role="tablist" aria-label="View mode">
<button type="button" class="view-btn${this._viewMode === 'grid' ? ' view-btn--active' : ''}" data-view="grid" role="tab" aria-selected="${this._viewMode === 'grid'}"> Grid</button>
<button type="button" class="view-btn${this._viewMode === 'timeline' ? ' view-btn--active' : ''}" data-view="timeline" role="tab" aria-selected="${this._viewMode === 'timeline'}">🕒 Timeline</button>
</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">
const emptyHtml = `<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>`;
const gridHtml = filteredPosts.length === 0 && threadsVisible.length === 0
? emptyHtml
: `<div class="grid">
${filteredPosts.map(p => {
const preview = this.esc(p.content.substring(0, 200));
@ -280,6 +338,9 @@ export class FolkThreadGallery extends HTMLElement {
}).join('')}
</div>`;
const timelineHtml = this._viewMode === 'timeline' ? this.renderTimeline(filteredPosts) : '';
const bodyHtml = this._viewMode === 'timeline' ? timelineHtml : gridHtml;
this.shadowRoot.innerHTML = `
<style>
:host { display: block; }
@ -321,9 +382,185 @@ export class FolkThreadGallery extends HTMLElement {
.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;
display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem;
justify-content: center;
}
.view-toggle {
display: inline-flex; gap: 0; margin: 0 auto 1.25rem; border-radius: 10px;
background: var(--rs-bg-surface, #1e293b);
border: 1px solid var(--rs-input-border, #334155);
padding: 3px;
width: fit-content; display: flex; justify-content: center;
}
.view-btn {
background: transparent; border: none; color: var(--rs-text-secondary, #94a3b8);
padding: 0.4rem 1rem; border-radius: 7px; font-size: 0.82rem; font-weight: 600;
cursor: pointer; font-family: inherit;
display: inline-flex; align-items: center; gap: 0.4rem;
transition: background 0.12s, color 0.12s;
}
.view-btn:hover { color: var(--rs-text-primary, #e2e8f0); }
.view-btn--active {
background: rgba(20, 184, 166, 0.18);
color: #5eead4;
}
.gallery--timeline { max-width: 1400px; }
.gallery--timeline .header h1 { font-size: 1.3rem; }
/* ── Timeline ── */
.timeline-wrap {
background: var(--rs-bg-surface, #1e293b);
border: 1px solid var(--rs-input-border, #334155);
border-radius: 12px;
overflow: hidden;
display: flex; flex-direction: column;
}
.timeline-toolbar {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--rs-input-border, #334155);
background: var(--rs-bg-surface-raised, #0f172a);
}
.tl-btn {
background: transparent; border: 1px solid var(--rs-input-border, #334155);
color: var(--rs-text-secondary, #94a3b8);
padding: 0.3rem 0.7rem; border-radius: 6px; font-size: 0.75rem; font-weight: 600;
cursor: pointer; font-family: inherit;
transition: border-color 0.12s, color 0.12s, background 0.12s;
}
.tl-btn:hover { border-color: #14b8a6; color: #5eead4; }
.tl-spacer { flex: 1; }
.tl-zoom-level {
color: var(--rs-text-secondary, #94a3b8);
font-size: 0.72rem; font-weight: 600;
min-width: 38px; text-align: center;
font-variant-numeric: tabular-nums;
}
.timeline-scroller {
overflow: auto; max-height: 640px;
scrollbar-width: thin;
}
.timeline-inner {
position: relative;
}
.timeline-header {
position: sticky; top: 0;
z-index: 3;
background: linear-gradient(to bottom, var(--rs-bg-surface, #1e293b) 70%, transparent);
}
.day-marker {
position: absolute; top: 4px;
color: var(--rs-text-primary, #f1f5f9);
font-size: 0.75rem; font-weight: 700;
padding: 0.25rem 0.5rem;
background: rgba(20,184,166,0.12);
border-radius: 4px;
white-space: nowrap;
transform: translateX(4px);
pointer-events: none;
}
.day-marker span { letter-spacing: 0.02em; }
.day-divider {
position: absolute; top: ${TIMELINE_HEADER_H}px;
width: 1px;
background: var(--rs-input-border, #334155);
pointer-events: none;
}
.hour-tick {
position: absolute; top: 30px;
color: var(--rs-text-muted, #64748b);
font-size: 0.62rem; font-weight: 600;
transform: translateX(2px);
pointer-events: none;
font-variant-numeric: tabular-nums;
}
.hour-divider {
position: absolute; top: ${TIMELINE_HEADER_H}px;
width: 1px;
background: rgba(255,255,255,0.04);
pointer-events: none;
}
.today-line {
position: absolute; top: -${TIMELINE_HEADER_H}px;
width: 2px;
background: #ef4444;
pointer-events: none;
z-index: 2;
}
.today-line__label {
position: absolute; top: 4px; left: 4px;
background: #ef4444;
color: #fff;
font-size: 0.6rem; font-weight: 700;
padding: 0.1rem 0.35rem; border-radius: 3px;
white-space: nowrap;
}
.lane-labels {
position: sticky; left: 0;
width: ${TIMELINE_LEFT_GUTTER}px;
float: left;
z-index: 4;
background: linear-gradient(to right, var(--rs-bg-surface, #1e293b) 70%, transparent);
}
.lane-label {
position: absolute; left: 0;
width: ${TIMELINE_LEFT_GUTTER - 8}px;
padding: 0 0.6rem;
display: flex; align-items: center;
color: var(--rs-text-secondary, #94a3b8);
font-size: 0.78rem; font-weight: 600;
border-right: 1px solid var(--rs-input-border, #334155);
}
.timeline-lanes {
position: relative;
}
.lane-bg {
position: absolute; left: 0;
background: transparent;
border-bottom: 1px dashed rgba(255,255,255,0.04);
}
.timeline-card {
position: absolute;
background: var(--rs-bg-surface-raised, #0f172a);
border: 1px solid var(--rs-input-border, #334155);
border-radius: 8px;
padding: 0.5rem 0.65rem;
cursor: pointer;
font-family: inherit; text-align: left;
color: inherit;
display: flex; flex-direction: column; gap: 0.2rem;
overflow: hidden;
transition: transform 0.12s, border-color 0.12s, box-shadow 0.12s;
z-index: 1;
}
.timeline-card:hover {
transform: translateY(-2px);
border-color: #14b8a6;
box-shadow: 0 6px 16px -8px rgba(0,0,0,0.45);
z-index: 2;
}
.timeline-card:focus-visible { outline: 2px solid #14b8a6; outline-offset: 2px; }
.tl-card__head {
display: flex; align-items: center; gap: 0.4rem;
font-size: 0.7rem;
}
.tl-icon { font-size: 0.95rem; line-height: 1; }
.tl-time {
color: var(--rs-text-muted, #64748b);
font-variant-numeric: tabular-nums;
margin-right: auto;
}
.tl-badge {
background: rgba(20,184,166,0.15); color: #5eead4;
font-size: 0.62rem; font-weight: 700;
padding: 0.1rem 0.35rem; border-radius: 3px;
}
.tl-card__preview {
font-size: 0.78rem;
color: var(--rs-text-secondary, #94a3b8);
line-height: 1.3;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.chip {
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.4rem 0.85rem; border-radius: 999px;
@ -570,13 +807,14 @@ export class FolkThreadGallery extends HTMLElement {
font-size: 1rem; line-height: 1; font-weight: 700;
}
</style>
<div class="gallery">
<div class="gallery gallery--${this._viewMode}">
<div class="header">
<h1>Posts & Threads</h1>
<a href="${this.basePath}thread-editor" class="btn btn--primary">New Thread</a>
</div>
${filterBar}
${threadCardsHTML}
${viewToggle}
${bodyHtml}
</div>
${this.renderOverlay()}
`;
@ -603,6 +841,236 @@ export class FolkThreadGallery extends HTMLElement {
// Overlay wiring
this.wireOverlay();
// View toggle
this.shadowRoot.querySelectorAll<HTMLElement>('.view-btn[data-view]').forEach(btn => {
btn.addEventListener('click', () => {
const v = btn.dataset.view as ViewMode | undefined;
if (!v || v === this._viewMode) return;
this._viewMode = v;
try { localStorage.setItem(VIEW_STORAGE_KEY, v); } catch { /* ignore */ }
this._pendingTimelineScrollToNow = v === 'timeline';
this.render();
});
});
// Timeline wiring
if (this._viewMode === 'timeline') this.wireTimeline();
}
// ── Timeline ──
private renderTimeline(posts: DraftPostCard[]): string {
// Only posts with a valid scheduledAt make sense on a time axis.
const dated = posts
.map(p => ({ ...p, ts: p.scheduledAt ? new Date(p.scheduledAt).getTime() : NaN }))
.filter(p => isFinite(p.ts))
.sort((a, b) => a.ts - b.ts);
if (dated.length === 0) {
return `<div class="empty">
<p>No ${this._statusFilter} posts with scheduled dates.</p>
<div style="margin-top:0.5rem;font-size:0.82rem;color:#64748b">Switch to Grid to see posts without a schedule.</div>
</div>`;
}
const pxPerHour = TIMELINE_BASE_PX_PER_HOUR * this._timelineZoom;
const tickHours = tickHoursForZoom(pxPerHour);
const showHourLabels = tickHours < 24;
// Range: floor(first) to ceil(last) with a day padding either side
const minTs = Math.min(...dated.map(p => p.ts));
const maxTs = Math.max(...dated.map(p => p.ts));
const startDate = floorToDay(minTs - 24 * 3600 * 1000);
const endDate = ceilToDay(maxTs + 24 * 3600 * 1000);
const totalHours = Math.max(24, (endDate - startDate) / 3600000);
const totalWidth = totalHours * pxPerHour;
const timeToX = (ts: number) => ((ts - startDate) / 3600000) * pxPerHour;
// Lane layout: one lane per platform actually present, in canonical order
const laneKeys: string[] = [];
for (const p of TIMELINE_PLATFORMS) {
if (dated.some(d => platformLaneKey(d.platform) === p)) laneKeys.push(p);
}
const laneY = (key: string) => laneKeys.indexOf(key) * TIMELINE_LANE_HEIGHT;
const lanesHeight = Math.max(laneKeys.length, 1) * TIMELINE_LANE_HEIGHT;
// Day + tick markers
const dayMarkers: string[] = [];
for (let t = startDate; t < endDate; t += 24 * 3600000) {
const x = timeToX(t);
const label = new Date(t).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
dayMarkers.push(`<div class="day-marker" style="left:${x}px"><span>${label}</span></div>`);
dayMarkers.push(`<div class="day-divider" style="left:${x}px;height:${lanesHeight}px"></div>`);
}
const tickMarkers: string[] = [];
if (showHourLabels) {
for (let t = startDate; t < endDate; t += tickHours * 3600000) {
const d = new Date(t);
if (d.getHours() === 0) continue; // day-marker already covers midnight
const x = timeToX(t);
const hr = d.getHours();
const lbl = hr === 12 ? '12p' : hr > 12 ? `${hr - 12}p` : `${hr}a`;
tickMarkers.push(`<div class="hour-tick" style="left:${x}px"><span>${lbl}</span></div>`);
tickMarkers.push(`<div class="hour-divider" style="left:${x}px;height:${lanesHeight}px"></div>`);
}
}
// Today marker
const nowTs = Date.now();
let todayLine = '';
if (nowTs >= startDate && nowTs <= endDate) {
const x = timeToX(nowTs);
todayLine = `<div class="today-line" style="left:${x}px;height:${lanesHeight + TIMELINE_HEADER_H}px" title="Now"><span class="today-line__label">Now</span></div>`;
}
// Lane label column (sticky)
const laneLabels = laneKeys.map(k => {
const y = laneY(k);
return `<div class="lane-label" style="top:${y + TIMELINE_HEADER_H}px;height:${TIMELINE_LANE_HEIGHT}px">${this.esc(PLATFORM_LANE_LABELS[k] || k)}</div>`;
}).join('');
// Lane background rows
const laneBackgrounds = laneKeys.map(k => {
const y = laneY(k);
return `<div class="lane-bg" style="top:${y}px;height:${TIMELINE_LANE_HEIGHT}px;width:${totalWidth}px"></div>`;
}).join('');
// Cards
const cardsHtml = dated.map(p => {
const x = timeToX(p.ts);
const y = laneY(platformLaneKey(p.platform)) + (TIMELINE_LANE_HEIGHT - TIMELINE_CARD_H) / 2;
const timeStr = new Date(p.ts).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
const tweetCount = p.threadPosts?.length ?? 0;
const threadBadge = tweetCount > 1 ? `<span class="tl-badge">🧵 ${tweetCount}</span>` : '';
const preview = this.esc(p.content.substring(0, 60));
return `<button type="button" class="timeline-card card--${p.status}"
style="left:${x}px;top:${y}px;width:${TIMELINE_CARD_W}px;height:${TIMELINE_CARD_H}px"
data-post-id="${this.esc(p.id)}">
<div class="tl-card__head">
<span class="tl-icon">${this.platformIcon(p.platform)}</span>
<span class="tl-time">${timeStr}</span>
${threadBadge}
</div>
<div class="tl-card__preview">${preview || '<em style=\"color:#64748b\">Empty</em>'}</div>
</button>`;
}).join('');
const zoomPct = Math.round(this._timelineZoom * 100);
return `<div class="timeline-wrap" role="region" aria-label="Scheduled posts timeline">
<div class="timeline-toolbar">
<button type="button" class="tl-btn" data-action="tl-fit" title="Fit all"> Fit</button>
<button type="button" class="tl-btn" data-action="tl-now" title="Scroll to now">📍 Now</button>
<div class="tl-spacer"></div>
<button type="button" class="tl-btn" data-action="tl-zoom-out" title="Zoom out"></button>
<span class="tl-zoom-level">${zoomPct}%</span>
<button type="button" class="tl-btn" data-action="tl-zoom-in" title="Zoom in">+</button>
</div>
<div class="timeline-scroller" id="timeline-scroller">
<div class="timeline-inner" style="width:${totalWidth + TIMELINE_LEFT_GUTTER}px;height:${lanesHeight + TIMELINE_HEADER_H}px">
<div class="timeline-header" style="width:${totalWidth}px;height:${TIMELINE_HEADER_H}px;margin-left:${TIMELINE_LEFT_GUTTER}px">
${dayMarkers.join('')}
${tickMarkers.join('')}
</div>
<div class="lane-labels" style="height:${lanesHeight}px">${laneLabels}</div>
<div class="timeline-lanes" style="margin-left:${TIMELINE_LEFT_GUTTER}px;width:${totalWidth}px;height:${lanesHeight}px">
${laneBackgrounds}
${todayLine}
${cardsHtml}
</div>
</div>
</div>
</div>`;
}
private wireTimeline(): void {
if (!this.shadowRoot) return;
const scroller = this.shadowRoot.getElementById('timeline-scroller') as HTMLElement | null;
if (!scroller) return;
// Restore scroll
if (this._pendingTimelineScrollToNow) {
this._pendingTimelineScrollToNow = false;
requestAnimationFrame(() => this.scrollTimelineToNow(scroller));
} else {
scroller.scrollLeft = this._timelineScrollLeft;
}
scroller.addEventListener('scroll', () => {
this._timelineScrollLeft = scroller.scrollLeft;
}, { passive: true });
// Ctrl/Cmd+wheel or pinch → zoom; plain wheel → let browser scroll
scroller.addEventListener('wheel', (e: WheelEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
const factor = 1 - e.deltaY * 0.008;
this.setTimelineZoom(this._timelineZoom * factor, e.clientX - scroller.getBoundingClientRect().left + scroller.scrollLeft);
}
}, { passive: false });
// Toolbar buttons
this.shadowRoot.querySelectorAll<HTMLElement>('.tl-btn[data-action]').forEach(btn => {
btn.addEventListener('click', () => {
const action = btn.dataset.action;
if (action === 'tl-zoom-in') this.setTimelineZoom(this._timelineZoom * 1.25);
else if (action === 'tl-zoom-out') this.setTimelineZoom(this._timelineZoom / 1.25);
else if (action === 'tl-fit') this.fitTimeline();
else if (action === 'tl-now') this.scrollTimelineToNow(scroller);
});
});
// Card clicks → overlay
this.shadowRoot.querySelectorAll('.timeline-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);
});
});
}
private setTimelineZoom(newZoom: number, anchorX?: number): void {
const clamped = Math.min(TIMELINE_ZOOM_MAX, Math.max(TIMELINE_ZOOM_MIN, newZoom));
if (Math.abs(clamped - this._timelineZoom) < 1e-4) return;
const scroller = this.shadowRoot?.getElementById('timeline-scroller') as HTMLElement | null;
// Preserve the focal point — recompute scroll so the anchor stays put
if (scroller && anchorX !== undefined) {
const ratio = clamped / this._timelineZoom;
this._timelineScrollLeft = anchorX * ratio - (anchorX - scroller.scrollLeft);
}
this._timelineZoom = clamped;
try { localStorage.setItem(TIMELINE_ZOOM_STORAGE_KEY, String(clamped)); } catch { /* ignore */ }
this.render();
}
private fitTimeline(): void {
const dated = this._allPosts
.filter(p => p.status === this._statusFilter && p.scheduledAt)
.map(p => new Date(p.scheduledAt).getTime())
.filter(t => isFinite(t));
if (dated.length === 0) return;
const minTs = Math.min(...dated) - 24 * 3600000;
const maxTs = Math.max(...dated) + 24 * 3600000;
const totalHours = (maxTs - minTs) / 3600000;
const scroller = this.shadowRoot?.getElementById('timeline-scroller') as HTMLElement | null;
if (!scroller) return;
const availableWidth = Math.max(400, scroller.clientWidth - TIMELINE_LEFT_GUTTER - 40);
const desiredPxPerHour = availableWidth / totalHours;
const zoom = desiredPxPerHour / TIMELINE_BASE_PX_PER_HOUR;
this._timelineScrollLeft = 0;
this.setTimelineZoom(zoom);
}
private scrollTimelineToNow(scroller: HTMLElement): void {
const nowLine = scroller.querySelector('.today-line') as HTMLElement | null;
if (!nowLine) return;
const x = parseFloat(nowLine.style.left);
if (!isFinite(x)) return;
scroller.scrollTo({ left: Math.max(0, x + TIMELINE_LEFT_GUTTER - scroller.clientWidth / 2), behavior: 'smooth' });
}
// ── Overlay rendering ──
@ -959,6 +1427,17 @@ export class FolkThreadGallery extends HTMLElement {
}
}
function floorToDay(ts: number): number {
const d = new Date(ts);
d.setHours(0, 0, 0, 0);
return d.getTime();
}
function ceilToDay(ts: number): number {
const floored = floorToDay(ts);
return floored === ts ? floored : floored + 24 * 3600 * 1000;
}
function toDatetimeLocal(iso: string): string {
try {
const d = new Date(iso);

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