social-masonry 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1512 @@
1
+ 'use strict';
2
+
3
+ Object.defineProperty(exports, '__esModule', { value: true });
4
+
5
+ /**
6
+ * Social Masonry - Utility Functions
7
+ */
8
+ /**
9
+ * Debounce function execution
10
+ */
11
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
12
+ function debounce(fn, delay) {
13
+ let timeoutId = null;
14
+ return function (...args) {
15
+ if (timeoutId) {
16
+ clearTimeout(timeoutId);
17
+ }
18
+ timeoutId = setTimeout(() => {
19
+ fn.apply(this, args);
20
+ timeoutId = null;
21
+ }, delay);
22
+ };
23
+ }
24
+ /**
25
+ * Throttle function execution
26
+ */
27
+ function throttle(fn, limit) {
28
+ let inThrottle = false;
29
+ let lastArgs = null;
30
+ return function (...args) {
31
+ if (!inThrottle) {
32
+ fn.apply(this, args);
33
+ inThrottle = true;
34
+ setTimeout(() => {
35
+ inThrottle = false;
36
+ if (lastArgs) {
37
+ fn.apply(this, lastArgs);
38
+ lastArgs = null;
39
+ }
40
+ }, limit);
41
+ }
42
+ else {
43
+ lastArgs = args;
44
+ }
45
+ };
46
+ }
47
+ /**
48
+ * Get number of columns based on viewport width
49
+ */
50
+ function getColumnCount(columns, containerWidth) {
51
+ if (typeof columns === 'number') {
52
+ return columns;
53
+ }
54
+ // Sort by minWidth descending
55
+ const sorted = [...columns].sort((a, b) => b.minWidth - a.minWidth);
56
+ for (const config of sorted) {
57
+ if (containerWidth >= config.minWidth) {
58
+ return config.columns;
59
+ }
60
+ }
61
+ // Return smallest breakpoint's columns or default to 1
62
+ return sorted[sorted.length - 1]?.columns ?? 1;
63
+ }
64
+ /**
65
+ * Default responsive column configuration
66
+ */
67
+ const defaultColumnConfig = [
68
+ { columns: 4, minWidth: 1200 },
69
+ { columns: 3, minWidth: 900 },
70
+ { columns: 2, minWidth: 600 },
71
+ { columns: 1, minWidth: 0 },
72
+ ];
73
+ /**
74
+ * Format number with abbreviations (1K, 1M, etc.)
75
+ */
76
+ function formatNumber(num) {
77
+ if (num >= 1000000) {
78
+ return `${(num / 1000000).toFixed(1).replace(/\.0$/, '')}M`;
79
+ }
80
+ if (num >= 1000) {
81
+ return `${(num / 1000).toFixed(1).replace(/\.0$/, '')}K`;
82
+ }
83
+ return num.toString();
84
+ }
85
+ /**
86
+ * Format relative time
87
+ */
88
+ function formatRelativeTime(date) {
89
+ const now = new Date();
90
+ const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
91
+ if (diffInSeconds < 60) {
92
+ return 'now';
93
+ }
94
+ const diffInMinutes = Math.floor(diffInSeconds / 60);
95
+ if (diffInMinutes < 60) {
96
+ return `${diffInMinutes}m`;
97
+ }
98
+ const diffInHours = Math.floor(diffInMinutes / 60);
99
+ if (diffInHours < 24) {
100
+ return `${diffInHours}h`;
101
+ }
102
+ const diffInDays = Math.floor(diffInHours / 24);
103
+ if (diffInDays < 7) {
104
+ return `${diffInDays}d`;
105
+ }
106
+ const diffInWeeks = Math.floor(diffInDays / 7);
107
+ if (diffInWeeks < 4) {
108
+ return `${diffInWeeks}w`;
109
+ }
110
+ // Format as date for older posts
111
+ return date.toLocaleDateString('en-US', {
112
+ month: 'short',
113
+ day: 'numeric',
114
+ });
115
+ }
116
+ /**
117
+ * Parse CSS value to pixels
118
+ */
119
+ function parseCSSValue(value, containerWidth) {
120
+ if (typeof value === 'number') {
121
+ return value;
122
+ }
123
+ const numMatch = value.match(/^([\d.]+)(px|rem|em|%|vw)?$/);
124
+ if (!numMatch) {
125
+ return 0;
126
+ }
127
+ const num = parseFloat(numMatch[1]);
128
+ const unit = numMatch[2] || 'px';
129
+ switch (unit) {
130
+ case 'px':
131
+ return num;
132
+ case 'rem':
133
+ return num * 16; // Assume 16px base
134
+ case 'em':
135
+ return num * 16;
136
+ case '%':
137
+ return 0;
138
+ case 'vw':
139
+ return (num / 100) * window.innerWidth;
140
+ default:
141
+ return num;
142
+ }
143
+ }
144
+ /**
145
+ * Generate unique ID
146
+ */
147
+ function generateId() {
148
+ return `sm-${Math.random().toString(36).substring(2, 9)}`;
149
+ }
150
+ /**
151
+ * Check if element is in viewport
152
+ */
153
+ function isInViewport(element, scrollTop, viewportHeight, overscan = 0) {
154
+ const expandedTop = scrollTop - overscan;
155
+ const expandedBottom = scrollTop + viewportHeight + overscan;
156
+ return element.bottom >= expandedTop && element.top <= expandedBottom;
157
+ }
158
+ /**
159
+ * Get scroll position
160
+ */
161
+ function getScrollPosition(scrollContainer) {
162
+ if (!scrollContainer || scrollContainer === window) {
163
+ return {
164
+ scrollTop: window.scrollY || document.documentElement.scrollTop,
165
+ viewportHeight: window.innerHeight,
166
+ };
167
+ }
168
+ return {
169
+ scrollTop: scrollContainer.scrollTop,
170
+ viewportHeight: scrollContainer.clientHeight,
171
+ };
172
+ }
173
+ /**
174
+ * Type guard for Twitter posts
175
+ */
176
+ function isTwitterPost(post) {
177
+ return post.platform === 'twitter';
178
+ }
179
+ /**
180
+ * Type guard for Instagram posts
181
+ */
182
+ function isInstagramPost(post) {
183
+ return post.platform === 'instagram';
184
+ }
185
+ /**
186
+ * Request animation frame with fallback
187
+ */
188
+ const raf = typeof requestAnimationFrame !== 'undefined'
189
+ ? requestAnimationFrame
190
+ : (callback) => setTimeout(callback, 16);
191
+ /**
192
+ * Cancel animation frame with fallback
193
+ */
194
+ const cancelRaf = typeof cancelAnimationFrame !== 'undefined'
195
+ ? cancelAnimationFrame
196
+ : (id) => clearTimeout(id);
197
+ /**
198
+ * Check if we're in a browser environment
199
+ */
200
+ const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined';
201
+ /**
202
+ * Check if ResizeObserver is supported
203
+ */
204
+ const supportsResizeObserver = isBrowser && typeof ResizeObserver !== 'undefined';
205
+
206
+ /**
207
+ * Social Masonry - Layout Engine
208
+ * Calculates positions for masonry grid items
209
+ */
210
+ class LayoutEngine {
211
+ constructor(options) {
212
+ this.options = {
213
+ gap: 16,
214
+ columns: defaultColumnConfig,
215
+ defaultColumns: 3,
216
+ padding: 0,
217
+ animationDuration: 300,
218
+ animate: true,
219
+ easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
220
+ ...options,
221
+ };
222
+ this.state = {
223
+ positions: new Map(),
224
+ columnHeights: [],
225
+ containerHeight: 0,
226
+ columnWidth: 0,
227
+ };
228
+ }
229
+ /**
230
+ * Calculate layout for all posts
231
+ */
232
+ calculate(posts) {
233
+ const { containerWidth, itemHeights } = this.options;
234
+ const gap = parseCSSValue(this.options.gap);
235
+ const padding = parseCSSValue(this.options.padding);
236
+ // Calculate column count and width
237
+ const columnCount = getColumnCount(this.options.columns, containerWidth);
238
+ const availableWidth = containerWidth - padding * 2;
239
+ const totalGapWidth = gap * (columnCount - 1);
240
+ const columnWidth = (availableWidth - totalGapWidth) / columnCount;
241
+ // Initialize column heights
242
+ const columnHeights = new Array(columnCount).fill(0);
243
+ const positions = new Map();
244
+ // Place each item in the shortest column
245
+ for (const post of posts) {
246
+ const itemHeight = itemHeights.get(post.id) ?? this.estimateHeight(post, columnWidth);
247
+ // Find shortest column
248
+ const shortestColumn = columnHeights.indexOf(Math.min(...columnHeights));
249
+ // Calculate position
250
+ const x = padding + shortestColumn * (columnWidth + gap);
251
+ const y = columnHeights[shortestColumn];
252
+ positions.set(post.id, {
253
+ id: post.id,
254
+ x,
255
+ y,
256
+ width: columnWidth,
257
+ height: itemHeight,
258
+ column: shortestColumn,
259
+ });
260
+ // Update column height
261
+ columnHeights[shortestColumn] = y + itemHeight + gap;
262
+ }
263
+ // Remove last gap from column heights
264
+ const containerHeight = Math.max(...columnHeights.map(h => h - gap), 0);
265
+ this.state = {
266
+ positions,
267
+ columnHeights,
268
+ containerHeight,
269
+ columnWidth,
270
+ };
271
+ return this.state;
272
+ }
273
+ /**
274
+ * Estimate item height based on content
275
+ */
276
+ estimateHeight(post, columnWidth) {
277
+ let height = 0;
278
+ // Header (avatar, name, etc.)
279
+ height += 56;
280
+ // Text content
281
+ if (post.platform === 'twitter') {
282
+ const textLength = post.content.text.length;
283
+ const avgCharsPerLine = Math.floor(columnWidth / 8); // ~8px per char
284
+ const lines = Math.ceil(textLength / avgCharsPerLine);
285
+ height += lines * 24; // ~24px per line
286
+ }
287
+ else if (post.content.caption) {
288
+ const captionLength = post.content.caption.length;
289
+ const avgCharsPerLine = Math.floor(columnWidth / 8);
290
+ const lines = Math.min(Math.ceil(captionLength / avgCharsPerLine), 4); // Max 4 lines
291
+ height += lines * 20;
292
+ }
293
+ // Media
294
+ if (post.platform === 'twitter' && post.media?.length) {
295
+ const media = post.media[0];
296
+ const aspectRatio = media.aspectRatio ?? 16 / 9;
297
+ height += columnWidth / aspectRatio;
298
+ }
299
+ else if (post.platform === 'instagram') {
300
+ const aspectRatio = post.media.aspectRatio ?? 1;
301
+ height += columnWidth / aspectRatio;
302
+ }
303
+ // Footer (metrics, timestamp)
304
+ height += 44;
305
+ // Padding
306
+ height += 24;
307
+ return Math.round(height);
308
+ }
309
+ /**
310
+ * Update single item height and recalculate affected items
311
+ */
312
+ updateItemHeight(id, height) {
313
+ this.options.itemHeights.set(id, height);
314
+ }
315
+ /**
316
+ * Get current layout state
317
+ */
318
+ getState() {
319
+ return this.state;
320
+ }
321
+ /**
322
+ * Get position for specific item
323
+ */
324
+ getPosition(id) {
325
+ return this.state.positions.get(id);
326
+ }
327
+ /**
328
+ * Update container width
329
+ */
330
+ setContainerWidth(width) {
331
+ this.options.containerWidth = width;
332
+ }
333
+ /**
334
+ * Get current column count
335
+ */
336
+ getColumnCount() {
337
+ return getColumnCount(this.options.columns, this.options.containerWidth);
338
+ }
339
+ /**
340
+ * Get CSS variables for animations
341
+ */
342
+ getCSSVariables() {
343
+ return {
344
+ '--sm-animation-duration': `${this.options.animationDuration}ms`,
345
+ '--sm-easing': this.options.easing,
346
+ '--sm-gap': typeof this.options.gap === 'number'
347
+ ? `${this.options.gap}px`
348
+ : this.options.gap,
349
+ '--sm-column-width': `${this.state.columnWidth}px`,
350
+ '--sm-container-height': `${this.state.containerHeight}px`,
351
+ };
352
+ }
353
+ }
354
+ /**
355
+ * Create a new layout engine instance
356
+ */
357
+ function createLayoutEngine(options) {
358
+ return new LayoutEngine(options);
359
+ }
360
+
361
+ /**
362
+ * Social Masonry - Virtualization Engine
363
+ * Handles virtual scrolling for large lists
364
+ */
365
+ class VirtualizationEngine {
366
+ constructor(options) {
367
+ this.visibleItems = [];
368
+ this.rafId = null;
369
+ this.lastScrollTop = 0;
370
+ this.isScrolling = false;
371
+ this.scrollEndTimeout = null;
372
+ this.options = {
373
+ enabled: true,
374
+ overscan: 3,
375
+ estimatedItemHeight: 400,
376
+ scrollContainer: null,
377
+ ...options,
378
+ };
379
+ this.posts = options.posts;
380
+ this.positions = options.positions;
381
+ this.onVisibleItemsChange = options.onVisibleItemsChange;
382
+ this.scrollHandler = throttle(this.handleScroll.bind(this), 16);
383
+ }
384
+ /**
385
+ * Initialize scroll listener
386
+ */
387
+ init() {
388
+ if (!this.options.enabled)
389
+ return;
390
+ const container = this.options.scrollContainer ?? window;
391
+ container.addEventListener('scroll', this.scrollHandler, { passive: true });
392
+ // Initial calculation
393
+ this.calculateVisibleItems();
394
+ }
395
+ /**
396
+ * Destroy and cleanup
397
+ */
398
+ destroy() {
399
+ const container = this.options.scrollContainer ?? window;
400
+ container.removeEventListener('scroll', this.scrollHandler);
401
+ if (this.rafId !== null) {
402
+ cancelRaf(this.rafId);
403
+ }
404
+ if (this.scrollEndTimeout) {
405
+ clearTimeout(this.scrollEndTimeout);
406
+ }
407
+ }
408
+ /**
409
+ * Handle scroll event
410
+ */
411
+ handleScroll() {
412
+ if (this.rafId !== null)
413
+ return;
414
+ this.isScrolling = true;
415
+ // Clear existing scroll end timeout
416
+ if (this.scrollEndTimeout) {
417
+ clearTimeout(this.scrollEndTimeout);
418
+ }
419
+ // Set scroll end detection
420
+ this.scrollEndTimeout = setTimeout(() => {
421
+ this.isScrolling = false;
422
+ }, 150);
423
+ this.rafId = raf(() => {
424
+ this.rafId = null;
425
+ this.calculateVisibleItems();
426
+ });
427
+ }
428
+ /**
429
+ * Calculate which items are visible
430
+ */
431
+ calculateVisibleItems() {
432
+ const { scrollTop, viewportHeight } = getScrollPosition(this.options.scrollContainer);
433
+ const overscanPx = this.options.overscan * this.options.estimatedItemHeight;
434
+ this.lastScrollTop = scrollTop;
435
+ const newVisibleItems = [];
436
+ for (let i = 0; i < this.posts.length; i++) {
437
+ const post = this.posts[i];
438
+ const position = this.positions.get(post.id);
439
+ if (!position)
440
+ continue;
441
+ const isVisible = isInViewport({
442
+ top: position.y,
443
+ bottom: position.y + position.height,
444
+ }, scrollTop, viewportHeight, overscanPx);
445
+ if (isVisible) {
446
+ newVisibleItems.push({
447
+ index: i,
448
+ post,
449
+ position,
450
+ isVisible: true,
451
+ });
452
+ }
453
+ }
454
+ // Check if visible items changed
455
+ const hasChanged = this.hasVisibleItemsChanged(newVisibleItems);
456
+ if (hasChanged) {
457
+ this.visibleItems = newVisibleItems;
458
+ this.onVisibleItemsChange?.(newVisibleItems);
459
+ }
460
+ return this.visibleItems;
461
+ }
462
+ /**
463
+ * Check if visible items have changed
464
+ */
465
+ hasVisibleItemsChanged(newItems) {
466
+ if (newItems.length !== this.visibleItems.length) {
467
+ return true;
468
+ }
469
+ for (let i = 0; i < newItems.length; i++) {
470
+ if (newItems[i].post.id !== this.visibleItems[i].post.id) {
471
+ return true;
472
+ }
473
+ }
474
+ return false;
475
+ }
476
+ /**
477
+ * Update posts and positions
478
+ */
479
+ update(posts, positions) {
480
+ this.posts = posts;
481
+ this.positions = positions;
482
+ this.calculateVisibleItems();
483
+ }
484
+ /**
485
+ * Get visible items
486
+ */
487
+ getVisibleItems() {
488
+ return this.visibleItems;
489
+ }
490
+ /**
491
+ * Get all items (for non-virtualized mode)
492
+ */
493
+ getAllItems() {
494
+ return this.posts.map((post, index) => ({
495
+ index,
496
+ post,
497
+ position: this.positions.get(post.id),
498
+ isVisible: true,
499
+ }));
500
+ }
501
+ /**
502
+ * Check if scrolling
503
+ */
504
+ getIsScrolling() {
505
+ return this.isScrolling;
506
+ }
507
+ /**
508
+ * Get scroll direction
509
+ */
510
+ getScrollDirection() {
511
+ const { scrollTop } = getScrollPosition(this.options.scrollContainer);
512
+ if (scrollTop > this.lastScrollTop) {
513
+ return 'down';
514
+ }
515
+ else if (scrollTop < this.lastScrollTop) {
516
+ return 'up';
517
+ }
518
+ return 'none';
519
+ }
520
+ /**
521
+ * Check if near bottom (for infinite scroll)
522
+ */
523
+ isNearBottom(threshold = 500) {
524
+ const { scrollTop, viewportHeight } = getScrollPosition(this.options.scrollContainer);
525
+ // Get container height from positions
526
+ let maxBottom = 0;
527
+ for (const position of this.positions.values()) {
528
+ maxBottom = Math.max(maxBottom, position.y + position.height);
529
+ }
530
+ return scrollTop + viewportHeight >= maxBottom - threshold;
531
+ }
532
+ /**
533
+ * Scroll to item
534
+ */
535
+ scrollToItem(id, behavior = 'smooth') {
536
+ const position = this.positions.get(id);
537
+ if (!position)
538
+ return;
539
+ const container = this.options.scrollContainer ?? window;
540
+ if (container === window) {
541
+ window.scrollTo({
542
+ top: position.y,
543
+ behavior,
544
+ });
545
+ }
546
+ else {
547
+ container.scrollTo({
548
+ top: position.y,
549
+ behavior,
550
+ });
551
+ }
552
+ }
553
+ /**
554
+ * Get render range for optimization
555
+ */
556
+ getRenderRange() {
557
+ if (this.visibleItems.length === 0) {
558
+ return { start: 0, end: 0 };
559
+ }
560
+ const indices = this.visibleItems.map(item => item.index);
561
+ return {
562
+ start: Math.min(...indices),
563
+ end: Math.max(...indices) + 1,
564
+ };
565
+ }
566
+ }
567
+ /**
568
+ * Create a new virtualization engine instance
569
+ */
570
+ function createVirtualizationEngine(options) {
571
+ return new VirtualizationEngine(options);
572
+ }
573
+
574
+ /**
575
+ * Social Masonry - Card Renderer
576
+ * Renders social media post cards with proper styling
577
+ */
578
+ const DEFAULT_OPTIONS$1 = {
579
+ variant: 'default',
580
+ theme: 'auto',
581
+ borderRadius: 12,
582
+ showPlatformIcon: true,
583
+ showAuthor: true,
584
+ showMetrics: true,
585
+ showTimestamp: true,
586
+ formatDate: (date) => formatRelativeTime(date),
587
+ formatNumber: formatNumber,
588
+ className: '',
589
+ hoverEffect: true,
590
+ imageLoading: 'lazy',
591
+ fallbackImage: 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"%3E%3Crect fill="%23f0f0f0" width="100" height="100"/%3E%3Ctext fill="%23999" font-family="sans-serif" font-size="14" x="50%" y="50%" text-anchor="middle" dy=".3em"%3ENo Image%3C/text%3E%3C/svg%3E',
592
+ };
593
+ class CardRenderer {
594
+ constructor(options = {}) {
595
+ this.options = {
596
+ ...DEFAULT_OPTIONS$1,
597
+ onPostClick: undefined,
598
+ onAuthorClick: undefined,
599
+ onMediaClick: undefined,
600
+ onImageError: undefined,
601
+ ...options,
602
+ };
603
+ }
604
+ /**
605
+ * Render a single card
606
+ */
607
+ render(post, position) {
608
+ const card = document.createElement('div');
609
+ card.className = this.getCardClasses(post);
610
+ card.setAttribute('data-post-id', post.id);
611
+ card.setAttribute('data-platform', post.platform);
612
+ // Apply position styles
613
+ Object.assign(card.style, {
614
+ position: 'absolute',
615
+ left: `${position.x}px`,
616
+ top: `${position.y}px`,
617
+ width: `${position.width}px`,
618
+ borderRadius: typeof this.options.borderRadius === 'number'
619
+ ? `${this.options.borderRadius}px`
620
+ : this.options.borderRadius,
621
+ });
622
+ // Build card content
623
+ card.innerHTML = this.buildCardHTML(post);
624
+ // Attach event listeners
625
+ this.attachEventListeners(card, post);
626
+ return card;
627
+ }
628
+ /**
629
+ * Get CSS classes for card
630
+ */
631
+ getCardClasses(post) {
632
+ const classes = [
633
+ 'sm-card',
634
+ `sm-card--${post.platform}`,
635
+ `sm-card--${this.options.variant}`,
636
+ `sm-card--${this.options.theme}`,
637
+ ];
638
+ if (this.options.hoverEffect) {
639
+ classes.push('sm-card--hover');
640
+ }
641
+ if (this.options.className) {
642
+ classes.push(this.options.className);
643
+ }
644
+ return classes.join(' ');
645
+ }
646
+ /**
647
+ * Build card HTML
648
+ */
649
+ buildCardHTML(post) {
650
+ const parts = [];
651
+ // Platform icon
652
+ if (this.options.showPlatformIcon) {
653
+ parts.push(this.renderPlatformIcon(post.platform));
654
+ }
655
+ // Header with author
656
+ if (this.options.showAuthor) {
657
+ parts.push(this.renderHeader(post));
658
+ }
659
+ // Content
660
+ parts.push(this.renderContent(post));
661
+ // Media
662
+ parts.push(this.renderMedia(post));
663
+ // Footer with metrics
664
+ if (this.options.showMetrics || this.options.showTimestamp) {
665
+ parts.push(this.renderFooter(post));
666
+ }
667
+ return parts.join('');
668
+ }
669
+ /**
670
+ * Render platform icon
671
+ */
672
+ renderPlatformIcon(platform) {
673
+ const icons = {
674
+ twitter: `<svg viewBox="0 0 24 24" class="sm-platform-icon sm-platform-icon--twitter"><path fill="currentColor" d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>`,
675
+ instagram: `<svg viewBox="0 0 24 24" class="sm-platform-icon sm-platform-icon--instagram"><path fill="currentColor" d="M12 2c2.717 0 3.056.01 4.122.06 1.065.05 1.79.217 2.428.465.66.254 1.216.598 1.772 1.153a4.908 4.908 0 0 1 1.153 1.772c.247.637.415 1.363.465 2.428.047 1.066.06 1.405.06 4.122 0 2.717-.01 3.056-.06 4.122-.05 1.065-.218 1.79-.465 2.428a4.883 4.883 0 0 1-1.153 1.772 4.915 4.915 0 0 1-1.772 1.153c-.637.247-1.363.415-2.428.465-1.066.047-1.405.06-4.122.06-2.717 0-3.056-.01-4.122-.06-1.065-.05-1.79-.218-2.428-.465a4.89 4.89 0 0 1-1.772-1.153 4.904 4.904 0 0 1-1.153-1.772c-.248-.637-.415-1.363-.465-2.428C2.013 15.056 2 14.717 2 12c0-2.717.01-3.056.06-4.122.05-1.066.217-1.79.465-2.428a4.88 4.88 0 0 1 1.153-1.772A4.897 4.897 0 0 1 5.45 2.525c.638-.248 1.362-.415 2.428-.465C8.944 2.013 9.283 2 12 2zm0 1.802c-2.67 0-2.986.01-4.04.058-.976.045-1.505.207-1.858.344-.466.182-.8.398-1.15.748-.35.35-.566.684-.748 1.15-.137.353-.3.882-.344 1.857-.048 1.055-.058 1.37-.058 4.041 0 2.67.01 2.986.058 4.04.045.976.207 1.505.344 1.858.182.466.399.8.748 1.15.35.35.684.566 1.15.748.353.137.882.3 1.857.344 1.054.048 1.37.058 4.041.058 2.67 0 2.987-.01 4.04-.058.976-.045 1.505-.207 1.858-.344.466-.182.8-.398 1.15-.748.35-.35.566-.684.748-1.15.137-.353.3-.882.344-1.857.048-1.055.058-1.37.058-4.041 0-2.67-.01-2.986-.058-4.04-.045-.976-.207-1.505-.344-1.858a3.097 3.097 0 0 0-.748-1.15 3.098 3.098 0 0 0-1.15-.748c-.353-.137-.882-.3-1.857-.344-1.055-.048-1.37-.058-4.041-.058zm0 3.063a5.135 5.135 0 1 1 0 10.27 5.135 5.135 0 0 1 0-10.27zm0 8.468a3.333 3.333 0 1 0 0-6.666 3.333 3.333 0 0 0 0 6.666zm6.538-8.671a1.2 1.2 0 1 1-2.4 0 1.2 1.2 0 0 1 2.4 0z"/></svg>`,
676
+ };
677
+ return `<div class="sm-card__platform-icon">${icons[platform] || ''}</div>`;
678
+ }
679
+ /**
680
+ * Render card header
681
+ */
682
+ renderHeader(post) {
683
+ const author = post.author;
684
+ const avatarUrl = author.avatarUrl || this.options.fallbackImage;
685
+ const verifiedBadge = author.verified
686
+ ? `<svg class="sm-card__verified" viewBox="0 0 24 24"><path fill="currentColor" d="M22.25 12c0-1.43-.88-2.67-2.19-3.34.46-1.39.2-2.9-.81-3.91s-2.52-1.27-3.91-.81c-.66-1.31-1.91-2.19-3.34-2.19s-2.67.88-3.34 2.19c-1.39-.46-2.9-.2-3.91.81s-1.27 2.52-.81 3.91C2.63 9.33 1.75 10.57 1.75 12s.88 2.67 2.19 3.34c-.46 1.39-.2 2.9.81 3.91s2.52 1.27 3.91.81c.66 1.31 1.91 2.19 3.34 2.19s2.67-.88 3.34-2.19c1.39.46 2.9.2 3.91-.81s1.27-2.52.81-3.91c1.31-.67 2.19-1.91 2.19-3.34zm-11.71 4.2L6.8 12.46l1.41-1.42 2.26 2.26 4.8-5.23 1.47 1.36-6.2 6.77z"/></svg>`
687
+ : '';
688
+ return `
689
+ <div class="sm-card__header">
690
+ <img
691
+ src="${avatarUrl}"
692
+ alt="${author.displayName || author.username}"
693
+ class="sm-card__avatar"
694
+ loading="${this.options.imageLoading}"
695
+ onerror="this.src='${this.options.fallbackImage}'"
696
+ />
697
+ <div class="sm-card__author">
698
+ <span class="sm-card__author-name">
699
+ ${author.displayName || author.username}
700
+ ${verifiedBadge}
701
+ </span>
702
+ <span class="sm-card__author-handle">@${author.username}</span>
703
+ </div>
704
+ </div>
705
+ `;
706
+ }
707
+ /**
708
+ * Render card content
709
+ */
710
+ renderContent(post) {
711
+ if (isTwitterPost(post)) {
712
+ return this.renderTwitterContent(post);
713
+ }
714
+ else if (isInstagramPost(post)) {
715
+ return this.renderInstagramContent(post);
716
+ }
717
+ return '';
718
+ }
719
+ /**
720
+ * Render Twitter content
721
+ */
722
+ renderTwitterContent(post) {
723
+ const text = post.content.html || this.linkifyText(post.content.text);
724
+ let quotedPost = '';
725
+ if (post.quotedPost) {
726
+ quotedPost = `
727
+ <div class="sm-card__quoted">
728
+ <div class="sm-card__quoted-header">
729
+ <span class="sm-card__quoted-name">${post.quotedPost.author.displayName}</span>
730
+ <span class="sm-card__quoted-handle">@${post.quotedPost.author.username}</span>
731
+ </div>
732
+ <div class="sm-card__quoted-text">${post.quotedPost.content.text}</div>
733
+ </div>
734
+ `;
735
+ }
736
+ return `
737
+ <div class="sm-card__content">
738
+ <p class="sm-card__text">${text}</p>
739
+ ${quotedPost}
740
+ </div>
741
+ `;
742
+ }
743
+ /**
744
+ * Render Instagram content
745
+ */
746
+ renderInstagramContent(post) {
747
+ if (!post.content.caption)
748
+ return '';
749
+ // Truncate caption if too long
750
+ const maxLength = 150;
751
+ let caption = post.content.caption;
752
+ let showMore = false;
753
+ if (caption.length > maxLength) {
754
+ caption = caption.substring(0, maxLength);
755
+ showMore = true;
756
+ }
757
+ return `
758
+ <div class="sm-card__content">
759
+ <p class="sm-card__caption">
760
+ ${this.linkifyText(caption)}${showMore ? '<span class="sm-card__more">... more</span>' : ''}
761
+ </p>
762
+ </div>
763
+ `;
764
+ }
765
+ /**
766
+ * Render media
767
+ */
768
+ renderMedia(post) {
769
+ if (isTwitterPost(post)) {
770
+ return this.renderTwitterMedia(post);
771
+ }
772
+ else if (isInstagramPost(post)) {
773
+ return this.renderInstagramMedia(post);
774
+ }
775
+ return '';
776
+ }
777
+ /**
778
+ * Render Twitter media
779
+ */
780
+ renderTwitterMedia(post) {
781
+ if (!post.media?.length)
782
+ return '';
783
+ const mediaCount = post.media.length;
784
+ const gridClass = mediaCount > 1 ? `sm-card__media-grid sm-card__media-grid--${Math.min(mediaCount, 4)}` : '';
785
+ const mediaItems = post.media.slice(0, 4).map((media, index) => {
786
+ if (media.type === 'video' || media.type === 'gif') {
787
+ return `
788
+ <div class="sm-card__media-item sm-card__media-item--video" data-index="${index}">
789
+ <img
790
+ src="${media.thumbnailUrl || media.url}"
791
+ alt="Video thumbnail"
792
+ loading="${this.options.imageLoading}"
793
+ onerror="this.src='${this.options.fallbackImage}'"
794
+ />
795
+ <div class="sm-card__play-button">
796
+ <svg viewBox="0 0 24 24"><path fill="currentColor" d="M8 5v14l11-7z"/></svg>
797
+ </div>
798
+ </div>
799
+ `;
800
+ }
801
+ return `
802
+ <div class="sm-card__media-item" data-index="${index}">
803
+ <img
804
+ src="${media.url}"
805
+ alt="Post media"
806
+ loading="${this.options.imageLoading}"
807
+ onerror="this.src='${this.options.fallbackImage}'"
808
+ />
809
+ </div>
810
+ `;
811
+ }).join('');
812
+ return `<div class="sm-card__media ${gridClass}">${mediaItems}</div>`;
813
+ }
814
+ /**
815
+ * Render Instagram media
816
+ */
817
+ renderInstagramMedia(post) {
818
+ const media = post.media;
819
+ const aspectRatio = media.aspectRatio || 1;
820
+ if (media.type === 'carousel' && media.carouselItems?.length) {
821
+ return `
822
+ <div class="sm-card__media sm-card__carousel" style="aspect-ratio: ${aspectRatio}">
823
+ <img
824
+ src="${media.carouselItems[0].url}"
825
+ alt="Post media"
826
+ loading="${this.options.imageLoading}"
827
+ onerror="this.src='${this.options.fallbackImage}'"
828
+ />
829
+ <div class="sm-card__carousel-indicator">
830
+ <svg viewBox="0 0 24 24"><path fill="currentColor" d="M6 13c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1zm12 0c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1zm-6 0c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1z"/></svg>
831
+ </div>
832
+ </div>
833
+ `;
834
+ }
835
+ if (media.type === 'video') {
836
+ return `
837
+ <div class="sm-card__media sm-card__media--video" style="aspect-ratio: ${aspectRatio}">
838
+ <img
839
+ src="${media.thumbnailUrl || media.url}"
840
+ alt="Video thumbnail"
841
+ loading="${this.options.imageLoading}"
842
+ onerror="this.src='${this.options.fallbackImage}'"
843
+ />
844
+ <div class="sm-card__play-button">
845
+ <svg viewBox="0 0 24 24"><path fill="currentColor" d="M8 5v14l11-7z"/></svg>
846
+ </div>
847
+ </div>
848
+ `;
849
+ }
850
+ return `
851
+ <div class="sm-card__media" style="aspect-ratio: ${aspectRatio}">
852
+ <img
853
+ src="${media.url}"
854
+ alt="Post media"
855
+ loading="${this.options.imageLoading}"
856
+ onerror="this.src='${this.options.fallbackImage}'"
857
+ />
858
+ </div>
859
+ `;
860
+ }
861
+ /**
862
+ * Render footer with metrics
863
+ */
864
+ renderFooter(post) {
865
+ const parts = [];
866
+ if (this.options.showMetrics && post.metrics) {
867
+ parts.push(this.renderMetrics(post));
868
+ }
869
+ if (this.options.showTimestamp) {
870
+ const date = post.createdAt instanceof Date
871
+ ? post.createdAt
872
+ : new Date(post.createdAt);
873
+ parts.push(`
874
+ <time class="sm-card__timestamp" datetime="${date.toISOString()}">
875
+ ${this.options.formatDate(date)}
876
+ </time>
877
+ `);
878
+ }
879
+ return `<div class="sm-card__footer">${parts.join('')}</div>`;
880
+ }
881
+ /**
882
+ * Render metrics
883
+ */
884
+ renderMetrics(post) {
885
+ if (!post.metrics)
886
+ return '';
887
+ const formatNum = this.options.formatNumber;
888
+ if (isTwitterPost(post)) {
889
+ const metrics = post.metrics;
890
+ return `
891
+ <div class="sm-card__metrics">
892
+ ${metrics.replies !== undefined ? `
893
+ <span class="sm-card__metric">
894
+ <svg viewBox="0 0 24 24"><path fill="currentColor" d="M1.751 10c0-4.42 3.584-8 8.005-8h4.366c4.49 0 8.129 3.64 8.129 8.13 0 2.96-1.607 5.68-4.196 7.11l-8.054 4.46v-3.69h-.067c-4.49.1-8.183-3.51-8.183-8.01zm8.005-6c-3.317 0-6.005 2.69-6.005 6 0 3.37 2.77 6.08 6.138 6.01l.351-.01h1.761v2.3l5.087-2.81c1.951-1.08 3.163-3.13 3.163-5.36 0-3.39-2.744-6.13-6.129-6.13H9.756z"/></svg>
895
+ ${formatNum(metrics.replies)}
896
+ </span>
897
+ ` : ''}
898
+ ${metrics.retweets !== undefined ? `
899
+ <span class="sm-card__metric">
900
+ <svg viewBox="0 0 24 24"><path fill="currentColor" d="M4.5 3.88l4.432 4.14-1.364 1.46L5.5 7.55V16c0 1.1.896 2 2 2H13v2H7.5c-2.209 0-4-1.79-4-4V7.55L1.432 9.48.068 8.02 4.5 3.88zM16.5 6H11V4h5.5c2.209 0 4 1.79 4 4v8.45l2.068-1.93 1.364 1.46-4.432 4.14-4.432-4.14 1.364-1.46 2.068 1.93V8c0-1.1-.896-2-2-2z"/></svg>
901
+ ${formatNum(metrics.retweets)}
902
+ </span>
903
+ ` : ''}
904
+ ${metrics.likes !== undefined ? `
905
+ <span class="sm-card__metric">
906
+ <svg viewBox="0 0 24 24"><path fill="currentColor" d="M16.697 5.5c-1.222-.06-2.679.51-3.89 2.16l-.805 1.09-.806-1.09C9.984 6.01 8.526 5.44 7.304 5.5c-1.243.07-2.349.78-2.91 1.91-.552 1.12-.633 2.78.479 4.82 1.074 1.97 3.257 4.27 7.129 6.61 3.87-2.34 6.052-4.64 7.126-6.61 1.111-2.04 1.03-3.7.477-4.82-.561-1.13-1.666-1.84-2.908-1.91zm4.187 7.69c-1.351 2.48-4.001 5.12-8.379 7.67l-.503.3-.504-.3c-4.379-2.55-7.029-5.19-8.382-7.67-1.36-2.5-1.41-4.86-.514-6.67.887-1.79 2.647-2.91 4.601-3.01 1.651-.09 3.368.56 4.798 2.01 1.429-1.45 3.146-2.1 4.796-2.01 1.954.1 3.714 1.22 4.601 3.01.896 1.81.846 4.17-.514 6.67z"/></svg>
907
+ ${formatNum(metrics.likes)}
908
+ </span>
909
+ ` : ''}
910
+ ${metrics.views !== undefined ? `
911
+ <span class="sm-card__metric">
912
+ <svg viewBox="0 0 24 24"><path fill="currentColor" d="M8.75 21V3h2v18h-2zM18 21V8.5h2V21h-2zM4 21l.004-10h2L6 21H4zm9.248 0v-7h2v7h-2z"/></svg>
913
+ ${formatNum(metrics.views)}
914
+ </span>
915
+ ` : ''}
916
+ </div>
917
+ `;
918
+ }
919
+ if (isInstagramPost(post)) {
920
+ const metrics = post.metrics;
921
+ return `
922
+ <div class="sm-card__metrics">
923
+ ${metrics.likes !== undefined ? `
924
+ <span class="sm-card__metric">
925
+ <svg viewBox="0 0 24 24"><path fill="currentColor" d="M16.792 3.904A4.989 4.989 0 0 1 21.5 9.122c0 3.072-2.652 4.959-5.197 7.222-2.512 2.243-3.865 3.469-4.303 3.752-.477-.309-2.143-1.823-4.303-3.752C5.141 14.072 2.5 12.167 2.5 9.122a4.989 4.989 0 0 1 4.708-5.218 4.21 4.21 0 0 1 3.675 1.941c.84 1.175.98 1.763 1.12 1.763s.278-.588 1.11-1.766a4.17 4.17 0 0 1 3.679-1.938m0-2a6.04 6.04 0 0 0-4.797 2.127 6.052 6.052 0 0 0-4.787-2.127A6.985 6.985 0 0 0 .5 9.122c0 3.61 2.55 5.827 5.015 7.97.283.246.569.494.853.747l1.027.918a44.998 44.998 0 0 0 3.518 3.018 2 2 0 0 0 2.174 0 45.263 45.263 0 0 0 3.626-3.115l.922-.824c.293-.26.59-.519.885-.774 2.334-2.025 4.98-4.32 4.98-7.94a6.985 6.985 0 0 0-6.708-7.218z"/></svg>
926
+ ${formatNum(metrics.likes)}
927
+ </span>
928
+ ` : ''}
929
+ ${metrics.comments !== undefined ? `
930
+ <span class="sm-card__metric">
931
+ <svg viewBox="0 0 24 24"><path fill="currentColor" d="M20.656 17.008a9.993 9.993 0 1 0-3.59 3.615L22 22l-1.344-4.992zM10 12a1 1 0 1 1 1 1 1 1 0 0 1-1-1zm-4 0a1 1 0 1 1 1 1 1 1 0 0 1-1-1zm8 0a1 1 0 1 1 1 1 1 1 0 0 1-1-1z"/></svg>
932
+ ${formatNum(metrics.comments)}
933
+ </span>
934
+ ` : ''}
935
+ </div>
936
+ `;
937
+ }
938
+ return '';
939
+ }
940
+ /**
941
+ * Linkify text (URLs, mentions, hashtags)
942
+ */
943
+ linkifyText(text) {
944
+ // Escape HTML
945
+ let result = text
946
+ .replace(/&/g, '&amp;')
947
+ .replace(/</g, '&lt;')
948
+ .replace(/>/g, '&gt;');
949
+ // URLs
950
+ result = result.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer" class="sm-card__link">$1</a>');
951
+ // Mentions
952
+ result = result.replace(/@(\w+)/g, '<a href="#" class="sm-card__mention" data-username="$1">@$1</a>');
953
+ // Hashtags
954
+ result = result.replace(/#(\w+)/g, '<a href="#" class="sm-card__hashtag" data-tag="$1">#$1</a>');
955
+ return result;
956
+ }
957
+ /**
958
+ * Attach event listeners
959
+ */
960
+ attachEventListeners(card, post) {
961
+ // Post click
962
+ if (this.options.onPostClick) {
963
+ card.addEventListener('click', (e) => {
964
+ // Don't fire for links, buttons, etc.
965
+ const target = e.target;
966
+ if (target.tagName === 'A' || target.closest('a'))
967
+ return;
968
+ this.options.onPostClick?.(post, e);
969
+ });
970
+ card.style.cursor = 'pointer';
971
+ }
972
+ // Author click
973
+ if (this.options.onAuthorClick) {
974
+ const header = card.querySelector('.sm-card__header');
975
+ if (header) {
976
+ header.addEventListener('click', (e) => {
977
+ e.stopPropagation();
978
+ this.options.onAuthorClick?.(post, e);
979
+ });
980
+ header.style.cursor = 'pointer';
981
+ }
982
+ }
983
+ // Media click
984
+ if (this.options.onMediaClick) {
985
+ const mediaItems = card.querySelectorAll('.sm-card__media-item');
986
+ mediaItems.forEach((item) => {
987
+ item.addEventListener('click', (e) => {
988
+ e.stopPropagation();
989
+ const index = parseInt(item.dataset.index || '0', 10);
990
+ this.options.onMediaClick?.(post, index, e);
991
+ });
992
+ item.style.cursor = 'pointer';
993
+ });
994
+ }
995
+ // Image error handling
996
+ if (this.options.onImageError) {
997
+ const images = card.querySelectorAll('img');
998
+ images.forEach((img) => {
999
+ img.addEventListener('error', () => {
1000
+ this.options.onImageError?.(post, new Error('Image failed to load'));
1001
+ });
1002
+ });
1003
+ }
1004
+ }
1005
+ /**
1006
+ * Update card position
1007
+ */
1008
+ updatePosition(card, position) {
1009
+ Object.assign(card.style, {
1010
+ left: `${position.x}px`,
1011
+ top: `${position.y}px`,
1012
+ width: `${position.width}px`,
1013
+ });
1014
+ }
1015
+ /**
1016
+ * Update options
1017
+ */
1018
+ setOptions(options) {
1019
+ this.options = { ...this.options, ...options };
1020
+ }
1021
+ }
1022
+ /**
1023
+ * Create a new card renderer instance
1024
+ */
1025
+ function createCardRenderer(options) {
1026
+ return new CardRenderer(options);
1027
+ }
1028
+
1029
+ /**
1030
+ * Social Masonry - Main Class
1031
+ * Beautiful masonry layout for X (Twitter) and Instagram embeds
1032
+ */
1033
+ const DEFAULT_OPTIONS = {
1034
+ // Layout
1035
+ gap: 16,
1036
+ columns: defaultColumnConfig,
1037
+ defaultColumns: 3,
1038
+ padding: 0,
1039
+ animationDuration: 300,
1040
+ animate: true,
1041
+ easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
1042
+ // Card
1043
+ variant: 'default',
1044
+ theme: 'auto',
1045
+ borderRadius: 12,
1046
+ showPlatformIcon: true,
1047
+ showAuthor: true,
1048
+ showMetrics: true,
1049
+ showTimestamp: true,
1050
+ hoverEffect: true,
1051
+ imageLoading: 'lazy',
1052
+ // Virtualization
1053
+ virtualization: {
1054
+ enabled: false,
1055
+ overscan: 3,
1056
+ estimatedItemHeight: 400,
1057
+ scrollContainer: null,
1058
+ },
1059
+ // Infinite scroll
1060
+ loadMoreThreshold: 500,
1061
+ showLoading: true,
1062
+ // Debug
1063
+ debug: false,
1064
+ };
1065
+ class SocialMasonry {
1066
+ constructor(options) {
1067
+ this.posts = [];
1068
+ this.virtualizationEngine = null;
1069
+ this.cards = new Map();
1070
+ this.itemHeights = new Map();
1071
+ this.resizeObserver = null;
1072
+ this.isLoading = false;
1073
+ if (!isBrowser) {
1074
+ throw new Error('SocialMasonry requires a browser environment');
1075
+ }
1076
+ this.instanceId = generateId();
1077
+ this.options = { ...DEFAULT_OPTIONS, ...options };
1078
+ // Get container element
1079
+ const container = typeof options.container === 'string'
1080
+ ? document.querySelector(options.container)
1081
+ : options.container;
1082
+ if (!container) {
1083
+ throw new Error('Container element not found');
1084
+ }
1085
+ this.container = container;
1086
+ this.posts = options.posts || [];
1087
+ // Initialize layout engine
1088
+ this.layoutEngine = new LayoutEngine({
1089
+ ...this.options,
1090
+ containerWidth: this.container.clientWidth,
1091
+ itemHeights: this.itemHeights,
1092
+ });
1093
+ // Initialize card renderer
1094
+ this.cardRenderer = new CardRenderer({
1095
+ ...this.options,
1096
+ });
1097
+ // Initialize virtualization if enabled
1098
+ if (this.options.virtualization?.enabled) {
1099
+ this.initVirtualization();
1100
+ }
1101
+ // Setup
1102
+ this.setupContainer();
1103
+ this.setupResizeObserver();
1104
+ this.render();
1105
+ if (this.options.debug) {
1106
+ console.log('[SocialMasonry] Initialized', {
1107
+ instanceId: this.instanceId,
1108
+ posts: this.posts.length,
1109
+ containerWidth: this.container.clientWidth,
1110
+ });
1111
+ }
1112
+ }
1113
+ /**
1114
+ * Setup container styles
1115
+ */
1116
+ setupContainer() {
1117
+ this.container.classList.add('sm-container');
1118
+ this.container.setAttribute('data-sm-instance', this.instanceId);
1119
+ Object.assign(this.container.style, {
1120
+ position: 'relative',
1121
+ width: '100%',
1122
+ });
1123
+ }
1124
+ /**
1125
+ * Setup resize observer
1126
+ */
1127
+ setupResizeObserver() {
1128
+ if (!supportsResizeObserver) {
1129
+ // Fallback to window resize
1130
+ window.addEventListener('resize', debounce(() => this.handleResize(), 150));
1131
+ return;
1132
+ }
1133
+ this.resizeObserver = new ResizeObserver(debounce((entries) => {
1134
+ for (const entry of entries) {
1135
+ if (entry.target === this.container) {
1136
+ this.handleResize();
1137
+ }
1138
+ }
1139
+ }, 150));
1140
+ this.resizeObserver.observe(this.container);
1141
+ }
1142
+ /**
1143
+ * Handle container resize
1144
+ */
1145
+ handleResize() {
1146
+ const newWidth = this.container.clientWidth;
1147
+ this.layoutEngine.setContainerWidth(newWidth);
1148
+ this.recalculateLayout();
1149
+ if (this.options.debug) {
1150
+ console.log('[SocialMasonry] Resize', { width: newWidth });
1151
+ }
1152
+ }
1153
+ /**
1154
+ * Initialize virtualization
1155
+ */
1156
+ initVirtualization() {
1157
+ const state = this.layoutEngine.getState();
1158
+ this.virtualizationEngine = new VirtualizationEngine({
1159
+ ...this.options.virtualization,
1160
+ posts: this.posts,
1161
+ positions: state.positions,
1162
+ onVisibleItemsChange: (items) => this.handleVisibleItemsChange(items),
1163
+ });
1164
+ this.virtualizationEngine.init();
1165
+ // Setup infinite scroll
1166
+ if (this.options.onLoadMore) {
1167
+ this.setupInfiniteScroll();
1168
+ }
1169
+ }
1170
+ /**
1171
+ * Handle visible items change (for virtualization)
1172
+ */
1173
+ handleVisibleItemsChange(items) {
1174
+ const visibleIds = new Set(items.map(item => item.post.id));
1175
+ // Remove cards that are no longer visible
1176
+ for (const [id, card] of this.cards) {
1177
+ if (!visibleIds.has(id)) {
1178
+ card.remove();
1179
+ this.cards.delete(id);
1180
+ }
1181
+ }
1182
+ // Add new visible cards
1183
+ for (const item of items) {
1184
+ if (!this.cards.has(item.post.id)) {
1185
+ this.renderCard(item.post, item.position);
1186
+ }
1187
+ }
1188
+ }
1189
+ /**
1190
+ * Setup infinite scroll
1191
+ */
1192
+ setupInfiniteScroll() {
1193
+ const checkLoadMore = () => {
1194
+ if (!this.isLoading &&
1195
+ this.virtualizationEngine?.isNearBottom(this.options.loadMoreThreshold || 500)) {
1196
+ this.loadMore();
1197
+ }
1198
+ };
1199
+ const scrollContainer = this.options.virtualization?.scrollContainer ?? window;
1200
+ scrollContainer.addEventListener('scroll', checkLoadMore, { passive: true });
1201
+ }
1202
+ /**
1203
+ * Load more posts
1204
+ */
1205
+ async loadMore() {
1206
+ if (this.isLoading || !this.options.onLoadMore)
1207
+ return;
1208
+ this.isLoading = true;
1209
+ this.showLoadingIndicator();
1210
+ try {
1211
+ await this.options.onLoadMore();
1212
+ }
1213
+ finally {
1214
+ this.isLoading = false;
1215
+ this.hideLoadingIndicator();
1216
+ }
1217
+ }
1218
+ /**
1219
+ * Show loading indicator
1220
+ */
1221
+ showLoadingIndicator() {
1222
+ if (!this.options.showLoading)
1223
+ return;
1224
+ let loader = this.container.querySelector('.sm-loader');
1225
+ if (!loader) {
1226
+ loader = document.createElement('div');
1227
+ loader.className = 'sm-loader';
1228
+ if (this.options.loadingElement) {
1229
+ if (typeof this.options.loadingElement === 'string') {
1230
+ loader.innerHTML = this.options.loadingElement;
1231
+ }
1232
+ else {
1233
+ loader.appendChild(this.options.loadingElement.cloneNode(true));
1234
+ }
1235
+ }
1236
+ else {
1237
+ loader.innerHTML = `
1238
+ <div class="sm-loader__spinner">
1239
+ <div></div><div></div><div></div>
1240
+ </div>
1241
+ `;
1242
+ }
1243
+ this.container.appendChild(loader);
1244
+ }
1245
+ loader.style.display = 'flex';
1246
+ }
1247
+ /**
1248
+ * Hide loading indicator
1249
+ */
1250
+ hideLoadingIndicator() {
1251
+ const loader = this.container.querySelector('.sm-loader');
1252
+ if (loader) {
1253
+ loader.style.display = 'none';
1254
+ }
1255
+ }
1256
+ /**
1257
+ * Render all posts
1258
+ */
1259
+ render() {
1260
+ // Calculate layout
1261
+ const state = this.layoutEngine.calculate(this.posts);
1262
+ // Update container height
1263
+ this.container.style.height = `${state.containerHeight}px`;
1264
+ // Apply CSS variables
1265
+ const cssVars = this.layoutEngine.getCSSVariables();
1266
+ for (const [key, value] of Object.entries(cssVars)) {
1267
+ this.container.style.setProperty(key, value);
1268
+ }
1269
+ // Render cards
1270
+ if (this.virtualizationEngine) {
1271
+ // Virtualized rendering
1272
+ this.virtualizationEngine.update(this.posts, state.positions);
1273
+ const visibleItems = this.virtualizationEngine.calculateVisibleItems();
1274
+ this.handleVisibleItemsChange(visibleItems);
1275
+ }
1276
+ else {
1277
+ // Full rendering
1278
+ for (const post of this.posts) {
1279
+ const position = state.positions.get(post.id);
1280
+ if (position) {
1281
+ this.renderCard(post, position);
1282
+ }
1283
+ }
1284
+ }
1285
+ // Show empty state if no posts
1286
+ if (this.posts.length === 0) {
1287
+ this.showEmptyState();
1288
+ }
1289
+ else {
1290
+ this.hideEmptyState();
1291
+ }
1292
+ // Callback
1293
+ this.options.onLayoutComplete?.(Array.from(state.positions.values()));
1294
+ }
1295
+ /**
1296
+ * Render a single card
1297
+ */
1298
+ renderCard(post, position) {
1299
+ let card = this.cards.get(post.id);
1300
+ if (card) {
1301
+ // Update existing card position
1302
+ this.cardRenderer.updatePosition(card, position);
1303
+ }
1304
+ else {
1305
+ // Create new card
1306
+ card = this.cardRenderer.render(post, position);
1307
+ this.container.appendChild(card);
1308
+ this.cards.set(post.id, card);
1309
+ // Measure actual height after render
1310
+ requestAnimationFrame(() => {
1311
+ if (card) {
1312
+ const actualHeight = card.offsetHeight;
1313
+ if (actualHeight !== position.height) {
1314
+ this.itemHeights.set(post.id, actualHeight);
1315
+ this.recalculateLayout();
1316
+ }
1317
+ }
1318
+ });
1319
+ }
1320
+ }
1321
+ /**
1322
+ * Recalculate layout
1323
+ */
1324
+ recalculateLayout() {
1325
+ const state = this.layoutEngine.calculate(this.posts);
1326
+ this.container.style.height = `${state.containerHeight}px`;
1327
+ // Update virtualization
1328
+ if (this.virtualizationEngine) {
1329
+ this.virtualizationEngine.update(this.posts, state.positions);
1330
+ }
1331
+ // Update card positions with animation
1332
+ for (const [id, card] of this.cards) {
1333
+ const position = state.positions.get(id);
1334
+ if (position) {
1335
+ if (this.options.animate) {
1336
+ card.style.transition = `left ${this.options.animationDuration}ms ${this.options.easing}, top ${this.options.animationDuration}ms ${this.options.easing}, width ${this.options.animationDuration}ms ${this.options.easing}`;
1337
+ }
1338
+ this.cardRenderer.updatePosition(card, position);
1339
+ }
1340
+ }
1341
+ this.options.onLayoutComplete?.(Array.from(state.positions.values()));
1342
+ }
1343
+ /**
1344
+ * Show empty state
1345
+ */
1346
+ showEmptyState() {
1347
+ let empty = this.container.querySelector('.sm-empty');
1348
+ if (!empty) {
1349
+ empty = document.createElement('div');
1350
+ empty.className = 'sm-empty';
1351
+ if (this.options.emptyElement) {
1352
+ if (typeof this.options.emptyElement === 'string') {
1353
+ empty.innerHTML = this.options.emptyElement;
1354
+ }
1355
+ else {
1356
+ empty.appendChild(this.options.emptyElement.cloneNode(true));
1357
+ }
1358
+ }
1359
+ else {
1360
+ empty.textContent = this.options.emptyMessage || 'No posts to display';
1361
+ }
1362
+ this.container.appendChild(empty);
1363
+ }
1364
+ empty.style.display = 'flex';
1365
+ }
1366
+ /**
1367
+ * Hide empty state
1368
+ */
1369
+ hideEmptyState() {
1370
+ const empty = this.container.querySelector('.sm-empty');
1371
+ if (empty) {
1372
+ empty.style.display = 'none';
1373
+ }
1374
+ }
1375
+ // ============================================
1376
+ // Public API
1377
+ // ============================================
1378
+ /**
1379
+ * Add posts
1380
+ */
1381
+ addPosts(posts) {
1382
+ this.posts = [...this.posts, ...posts];
1383
+ this.render();
1384
+ if (this.options.debug) {
1385
+ console.log('[SocialMasonry] Added posts', { count: posts.length, total: this.posts.length });
1386
+ }
1387
+ }
1388
+ /**
1389
+ * Set posts (replace all)
1390
+ */
1391
+ setPosts(posts) {
1392
+ // Clear existing cards
1393
+ for (const card of this.cards.values()) {
1394
+ card.remove();
1395
+ }
1396
+ this.cards.clear();
1397
+ this.itemHeights.clear();
1398
+ this.posts = posts;
1399
+ this.render();
1400
+ if (this.options.debug) {
1401
+ console.log('[SocialMasonry] Set posts', { count: posts.length });
1402
+ }
1403
+ }
1404
+ /**
1405
+ * Remove post
1406
+ */
1407
+ removePost(id) {
1408
+ const card = this.cards.get(id);
1409
+ if (card) {
1410
+ card.remove();
1411
+ this.cards.delete(id);
1412
+ }
1413
+ this.itemHeights.delete(id);
1414
+ this.posts = this.posts.filter(p => p.id !== id);
1415
+ this.recalculateLayout();
1416
+ if (this.options.debug) {
1417
+ console.log('[SocialMasonry] Removed post', { id });
1418
+ }
1419
+ }
1420
+ /**
1421
+ * Update options
1422
+ */
1423
+ setOptions(options) {
1424
+ this.options = { ...this.options, ...options };
1425
+ this.cardRenderer.setOptions(options);
1426
+ this.recalculateLayout();
1427
+ }
1428
+ /**
1429
+ * Get layout state
1430
+ */
1431
+ getLayoutState() {
1432
+ return this.layoutEngine.getState();
1433
+ }
1434
+ /**
1435
+ * Get posts
1436
+ */
1437
+ getPosts() {
1438
+ return [...this.posts];
1439
+ }
1440
+ /**
1441
+ * Scroll to post
1442
+ */
1443
+ scrollToPost(id, behavior = 'smooth') {
1444
+ if (this.virtualizationEngine) {
1445
+ this.virtualizationEngine.scrollToItem(id, behavior);
1446
+ }
1447
+ else {
1448
+ const position = this.layoutEngine.getPosition(id);
1449
+ if (position) {
1450
+ window.scrollTo({
1451
+ top: position.y,
1452
+ behavior,
1453
+ });
1454
+ }
1455
+ }
1456
+ }
1457
+ /**
1458
+ * Refresh layout
1459
+ */
1460
+ refresh() {
1461
+ this.itemHeights.clear();
1462
+ this.recalculateLayout();
1463
+ }
1464
+ /**
1465
+ * Destroy instance
1466
+ */
1467
+ destroy() {
1468
+ // Remove cards
1469
+ for (const card of this.cards.values()) {
1470
+ card.remove();
1471
+ }
1472
+ this.cards.clear();
1473
+ // Remove observers
1474
+ this.resizeObserver?.disconnect();
1475
+ // Destroy virtualization
1476
+ this.virtualizationEngine?.destroy();
1477
+ // Clean up container
1478
+ this.container.classList.remove('sm-container');
1479
+ this.container.removeAttribute('data-sm-instance');
1480
+ this.container.style.cssText = '';
1481
+ // Remove internal elements
1482
+ const loader = this.container.querySelector('.sm-loader');
1483
+ const empty = this.container.querySelector('.sm-empty');
1484
+ loader?.remove();
1485
+ empty?.remove();
1486
+ if (this.options.debug) {
1487
+ console.log('[SocialMasonry] Destroyed', { instanceId: this.instanceId });
1488
+ }
1489
+ }
1490
+ }
1491
+ /**
1492
+ * Create a new SocialMasonry instance
1493
+ */
1494
+ function createSocialMasonry(options) {
1495
+ return new SocialMasonry(options);
1496
+ }
1497
+
1498
+ exports.CardRenderer = CardRenderer;
1499
+ exports.LayoutEngine = LayoutEngine;
1500
+ exports.SocialMasonry = SocialMasonry;
1501
+ exports.VirtualizationEngine = VirtualizationEngine;
1502
+ exports.createCardRenderer = createCardRenderer;
1503
+ exports.createLayoutEngine = createLayoutEngine;
1504
+ exports.createSocialMasonry = createSocialMasonry;
1505
+ exports.createVirtualizationEngine = createVirtualizationEngine;
1506
+ exports.default = SocialMasonry;
1507
+ exports.defaultColumnConfig = defaultColumnConfig;
1508
+ exports.formatNumber = formatNumber;
1509
+ exports.formatRelativeTime = formatRelativeTime;
1510
+ exports.isInstagramPost = isInstagramPost;
1511
+ exports.isTwitterPost = isTwitterPost;
1512
+ //# sourceMappingURL=index.js.map