merge(dev): rsocials timeline view
CI/CD / deploy (push) Successful in 4m14s
Details
CI/CD / deploy (push) Successful in 4m14s
Details
This commit is contained in:
commit
057288209d
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue