social-masonry 1.0.0 → 1.0.1

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.esm.js CHANGED
@@ -1,44 +1,86 @@
1
1
  /**
2
2
  * Social Masonry - Utility Functions
3
3
  */
4
+ // ============================================
5
+ // URL Parsing Utilities
6
+ // ============================================
4
7
  /**
5
- * Debounce function execution
8
+ * Extract tweet ID from Twitter/X URL
6
9
  */
7
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
8
- function debounce(fn, delay) {
9
- let timeoutId = null;
10
- return function (...args) {
11
- if (timeoutId) {
12
- clearTimeout(timeoutId);
13
- }
14
- timeoutId = setTimeout(() => {
15
- fn.apply(this, args);
16
- timeoutId = null;
17
- }, delay);
18
- };
10
+ function extractTweetId(url) {
11
+ if (!url)
12
+ return null;
13
+ const match = url.match(/(?:twitter\.com|x\.com)\/\w+\/status\/(\d+)/);
14
+ return match ? match[1] : null;
19
15
  }
20
16
  /**
21
- * Throttle function execution
17
+ * Extract post ID from Instagram URL
22
18
  */
23
- function throttle(fn, limit) {
24
- let inThrottle = false;
25
- let lastArgs = null;
26
- return function (...args) {
27
- if (!inThrottle) {
28
- fn.apply(this, args);
29
- inThrottle = true;
30
- setTimeout(() => {
31
- inThrottle = false;
32
- if (lastArgs) {
33
- fn.apply(this, lastArgs);
34
- lastArgs = null;
35
- }
36
- }, limit);
37
- }
38
- else {
39
- lastArgs = args;
40
- }
41
- };
19
+ function extractInstagramId(url) {
20
+ if (!url)
21
+ return null;
22
+ const match = url.match(/instagram\.com\/(?:p|reel)\/([A-Za-z0-9_-]+)/);
23
+ return match ? match[1] : null;
24
+ }
25
+ /**
26
+ * Detect platform from URL
27
+ */
28
+ function detectPlatform(url) {
29
+ if (/(?:twitter\.com|x\.com)\/\w+\/status\/\d+/.test(url)) {
30
+ return 'twitter';
31
+ }
32
+ if (/instagram\.com\/(?:p|reel)\/[A-Za-z0-9_-]+/.test(url)) {
33
+ return 'instagram';
34
+ }
35
+ return null;
36
+ }
37
+ /**
38
+ * Generate embed URL for Twitter
39
+ */
40
+ function getTwitterEmbedUrl(tweetId, options = {}) {
41
+ const params = new URLSearchParams();
42
+ params.set('id', tweetId);
43
+ if (options.theme)
44
+ params.set('theme', options.theme);
45
+ if (options.hideCard)
46
+ params.set('cards', 'hidden');
47
+ if (options.hideThread)
48
+ params.set('conversation', 'none');
49
+ return `https://platform.twitter.com/embed/Tweet.html?${params.toString()}`;
50
+ }
51
+ /**
52
+ * Generate embed URL for Instagram
53
+ */
54
+ function getInstagramEmbedUrl(postId) {
55
+ return `https://www.instagram.com/p/${postId}/embed/`;
56
+ }
57
+ /**
58
+ * Generate unique ID from post URL
59
+ */
60
+ function generatePostId(post) {
61
+ if (post.id)
62
+ return post.id;
63
+ if (post.platform === 'twitter') {
64
+ const tweetId = extractTweetId(post.url);
65
+ return tweetId ? `tw-${tweetId}` : `tw-${hashString(post.url)}`;
66
+ }
67
+ if (post.platform === 'instagram') {
68
+ const igId = extractInstagramId(post.url);
69
+ return igId ? `ig-${igId}` : `ig-${hashString(post.url)}`;
70
+ }
71
+ return `post-${hashString(post.url)}`;
72
+ }
73
+ /**
74
+ * Simple string hash function
75
+ */
76
+ function hashString(str) {
77
+ let hash = 0;
78
+ for (let i = 0; i < str.length; i++) {
79
+ const char = str.charCodeAt(i);
80
+ hash = ((hash << 5) - hash) + char;
81
+ hash = hash & hash;
82
+ }
83
+ return Math.abs(hash).toString(36);
42
84
  }
43
85
  /**
44
86
  * Get number of columns based on viewport width
@@ -67,137 +109,10 @@ const defaultColumnConfig = [
67
109
  { columns: 1, minWidth: 0 },
68
110
  ];
69
111
  /**
70
- * Format number with abbreviations (1K, 1M, etc.)
71
- */
72
- function formatNumber(num) {
73
- if (num >= 1000000) {
74
- return `${(num / 1000000).toFixed(1).replace(/\.0$/, '')}M`;
75
- }
76
- if (num >= 1000) {
77
- return `${(num / 1000).toFixed(1).replace(/\.0$/, '')}K`;
78
- }
79
- return num.toString();
80
- }
81
- /**
82
- * Format relative time
112
+ * Default embed heights
83
113
  */
84
- function formatRelativeTime(date) {
85
- const now = new Date();
86
- const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
87
- if (diffInSeconds < 60) {
88
- return 'now';
89
- }
90
- const diffInMinutes = Math.floor(diffInSeconds / 60);
91
- if (diffInMinutes < 60) {
92
- return `${diffInMinutes}m`;
93
- }
94
- const diffInHours = Math.floor(diffInMinutes / 60);
95
- if (diffInHours < 24) {
96
- return `${diffInHours}h`;
97
- }
98
- const diffInDays = Math.floor(diffInHours / 24);
99
- if (diffInDays < 7) {
100
- return `${diffInDays}d`;
101
- }
102
- const diffInWeeks = Math.floor(diffInDays / 7);
103
- if (diffInWeeks < 4) {
104
- return `${diffInWeeks}w`;
105
- }
106
- // Format as date for older posts
107
- return date.toLocaleDateString('en-US', {
108
- month: 'short',
109
- day: 'numeric',
110
- });
111
- }
112
- /**
113
- * Parse CSS value to pixels
114
- */
115
- function parseCSSValue(value, containerWidth) {
116
- if (typeof value === 'number') {
117
- return value;
118
- }
119
- const numMatch = value.match(/^([\d.]+)(px|rem|em|%|vw)?$/);
120
- if (!numMatch) {
121
- return 0;
122
- }
123
- const num = parseFloat(numMatch[1]);
124
- const unit = numMatch[2] || 'px';
125
- switch (unit) {
126
- case 'px':
127
- return num;
128
- case 'rem':
129
- return num * 16; // Assume 16px base
130
- case 'em':
131
- return num * 16;
132
- case '%':
133
- return 0;
134
- case 'vw':
135
- return (num / 100) * window.innerWidth;
136
- default:
137
- return num;
138
- }
139
- }
140
- /**
141
- * Generate unique ID
142
- */
143
- function generateId() {
144
- return `sm-${Math.random().toString(36).substring(2, 9)}`;
145
- }
146
- /**
147
- * Check if element is in viewport
148
- */
149
- function isInViewport(element, scrollTop, viewportHeight, overscan = 0) {
150
- const expandedTop = scrollTop - overscan;
151
- const expandedBottom = scrollTop + viewportHeight + overscan;
152
- return element.bottom >= expandedTop && element.top <= expandedBottom;
153
- }
154
- /**
155
- * Get scroll position
156
- */
157
- function getScrollPosition(scrollContainer) {
158
- if (!scrollContainer || scrollContainer === window) {
159
- return {
160
- scrollTop: window.scrollY || document.documentElement.scrollTop,
161
- viewportHeight: window.innerHeight,
162
- };
163
- }
164
- return {
165
- scrollTop: scrollContainer.scrollTop,
166
- viewportHeight: scrollContainer.clientHeight,
167
- };
168
- }
169
- /**
170
- * Type guard for Twitter posts
171
- */
172
- function isTwitterPost(post) {
173
- return post.platform === 'twitter';
174
- }
175
- /**
176
- * Type guard for Instagram posts
177
- */
178
- function isInstagramPost(post) {
179
- return post.platform === 'instagram';
180
- }
181
- /**
182
- * Request animation frame with fallback
183
- */
184
- const raf = typeof requestAnimationFrame !== 'undefined'
185
- ? requestAnimationFrame
186
- : (callback) => setTimeout(callback, 16);
187
- /**
188
- * Cancel animation frame with fallback
189
- */
190
- const cancelRaf = typeof cancelAnimationFrame !== 'undefined'
191
- ? cancelAnimationFrame
192
- : (id) => clearTimeout(id);
193
- /**
194
- * Check if we're in a browser environment
195
- */
196
- const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined';
197
- /**
198
- * Check if ResizeObserver is supported
199
- */
200
- const supportsResizeObserver = isBrowser && typeof ResizeObserver !== 'undefined';
114
+ const DEFAULT_TWITTER_HEIGHT = 500;
115
+ const DEFAULT_INSTAGRAM_HEIGHT = 600;
201
116
 
202
117
  /**
203
118
  * Social Masonry - Layout Engine
@@ -208,11 +123,11 @@ class LayoutEngine {
208
123
  this.options = {
209
124
  gap: 16,
210
125
  columns: defaultColumnConfig,
211
- defaultColumns: 3,
212
126
  padding: 0,
213
127
  animationDuration: 300,
214
128
  animate: true,
215
- easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
129
+ twitterHeight: DEFAULT_TWITTER_HEIGHT,
130
+ instagramHeight: DEFAULT_INSTAGRAM_HEIGHT,
216
131
  ...options,
217
132
  };
218
133
  this.state = {
@@ -226,9 +141,7 @@ class LayoutEngine {
226
141
  * Calculate layout for all posts
227
142
  */
228
143
  calculate(posts) {
229
- const { containerWidth, itemHeights } = this.options;
230
- const gap = parseCSSValue(this.options.gap);
231
- const padding = parseCSSValue(this.options.padding);
144
+ const { containerWidth, itemHeights, gap, padding } = this.options;
232
145
  // Calculate column count and width
233
146
  const columnCount = getColumnCount(this.options.columns, containerWidth);
234
147
  const availableWidth = containerWidth - padding * 2;
@@ -239,14 +152,15 @@ class LayoutEngine {
239
152
  const positions = new Map();
240
153
  // Place each item in the shortest column
241
154
  for (const post of posts) {
242
- const itemHeight = itemHeights.get(post.id) ?? this.estimateHeight(post, columnWidth);
155
+ const postId = generatePostId(post);
156
+ const itemHeight = itemHeights.get(postId) ?? this.getDefaultHeight(post);
243
157
  // Find shortest column
244
158
  const shortestColumn = columnHeights.indexOf(Math.min(...columnHeights));
245
159
  // Calculate position
246
160
  const x = padding + shortestColumn * (columnWidth + gap);
247
161
  const y = columnHeights[shortestColumn];
248
- positions.set(post.id, {
249
- id: post.id,
162
+ positions.set(postId, {
163
+ id: postId,
250
164
  x,
251
165
  y,
252
166
  width: columnWidth,
@@ -267,43 +181,19 @@ class LayoutEngine {
267
181
  return this.state;
268
182
  }
269
183
  /**
270
- * Estimate item height based on content
184
+ * Get default height for embed based on platform
271
185
  */
272
- estimateHeight(post, columnWidth) {
273
- let height = 0;
274
- // Header (avatar, name, etc.)
275
- height += 56;
276
- // Text content
186
+ getDefaultHeight(post) {
277
187
  if (post.platform === 'twitter') {
278
- const textLength = post.content.text.length;
279
- const avgCharsPerLine = Math.floor(columnWidth / 8); // ~8px per char
280
- const lines = Math.ceil(textLength / avgCharsPerLine);
281
- height += lines * 24; // ~24px per line
282
- }
283
- else if (post.content.caption) {
284
- const captionLength = post.content.caption.length;
285
- const avgCharsPerLine = Math.floor(columnWidth / 8);
286
- const lines = Math.min(Math.ceil(captionLength / avgCharsPerLine), 4); // Max 4 lines
287
- height += lines * 20;
288
- }
289
- // Media
290
- if (post.platform === 'twitter' && post.media?.length) {
291
- const media = post.media[0];
292
- const aspectRatio = media.aspectRatio ?? 16 / 9;
293
- height += columnWidth / aspectRatio;
188
+ return this.options.twitterHeight;
294
189
  }
295
- else if (post.platform === 'instagram') {
296
- const aspectRatio = post.media.aspectRatio ?? 1;
297
- height += columnWidth / aspectRatio;
190
+ if (post.platform === 'instagram') {
191
+ return this.options.instagramHeight;
298
192
  }
299
- // Footer (metrics, timestamp)
300
- height += 44;
301
- // Padding
302
- height += 24;
303
- return Math.round(height);
193
+ return DEFAULT_TWITTER_HEIGHT;
304
194
  }
305
195
  /**
306
- * Update single item height and recalculate affected items
196
+ * Update single item height
307
197
  */
308
198
  updateItemHeight(id, height) {
309
199
  this.options.itemHeights.set(id, height);
@@ -332,20 +222,6 @@ class LayoutEngine {
332
222
  getColumnCount() {
333
223
  return getColumnCount(this.options.columns, this.options.containerWidth);
334
224
  }
335
- /**
336
- * Get CSS variables for animations
337
- */
338
- getCSSVariables() {
339
- return {
340
- '--sm-animation-duration': `${this.options.animationDuration}ms`,
341
- '--sm-easing': this.options.easing,
342
- '--sm-gap': typeof this.options.gap === 'number'
343
- ? `${this.options.gap}px`
344
- : this.options.gap,
345
- '--sm-column-width': `${this.state.columnWidth}px`,
346
- '--sm-container-height': `${this.state.containerHeight}px`,
347
- };
348
- }
349
225
  }
350
226
  /**
351
227
  * Create a new layout engine instance
@@ -354,1142 +230,5 @@ function createLayoutEngine(options) {
354
230
  return new LayoutEngine(options);
355
231
  }
356
232
 
357
- /**
358
- * Social Masonry - Virtualization Engine
359
- * Handles virtual scrolling for large lists
360
- */
361
- class VirtualizationEngine {
362
- constructor(options) {
363
- this.visibleItems = [];
364
- this.rafId = null;
365
- this.lastScrollTop = 0;
366
- this.isScrolling = false;
367
- this.scrollEndTimeout = null;
368
- this.options = {
369
- enabled: true,
370
- overscan: 3,
371
- estimatedItemHeight: 400,
372
- scrollContainer: null,
373
- ...options,
374
- };
375
- this.posts = options.posts;
376
- this.positions = options.positions;
377
- this.onVisibleItemsChange = options.onVisibleItemsChange;
378
- this.scrollHandler = throttle(this.handleScroll.bind(this), 16);
379
- }
380
- /**
381
- * Initialize scroll listener
382
- */
383
- init() {
384
- if (!this.options.enabled)
385
- return;
386
- const container = this.options.scrollContainer ?? window;
387
- container.addEventListener('scroll', this.scrollHandler, { passive: true });
388
- // Initial calculation
389
- this.calculateVisibleItems();
390
- }
391
- /**
392
- * Destroy and cleanup
393
- */
394
- destroy() {
395
- const container = this.options.scrollContainer ?? window;
396
- container.removeEventListener('scroll', this.scrollHandler);
397
- if (this.rafId !== null) {
398
- cancelRaf(this.rafId);
399
- }
400
- if (this.scrollEndTimeout) {
401
- clearTimeout(this.scrollEndTimeout);
402
- }
403
- }
404
- /**
405
- * Handle scroll event
406
- */
407
- handleScroll() {
408
- if (this.rafId !== null)
409
- return;
410
- this.isScrolling = true;
411
- // Clear existing scroll end timeout
412
- if (this.scrollEndTimeout) {
413
- clearTimeout(this.scrollEndTimeout);
414
- }
415
- // Set scroll end detection
416
- this.scrollEndTimeout = setTimeout(() => {
417
- this.isScrolling = false;
418
- }, 150);
419
- this.rafId = raf(() => {
420
- this.rafId = null;
421
- this.calculateVisibleItems();
422
- });
423
- }
424
- /**
425
- * Calculate which items are visible
426
- */
427
- calculateVisibleItems() {
428
- const { scrollTop, viewportHeight } = getScrollPosition(this.options.scrollContainer);
429
- const overscanPx = this.options.overscan * this.options.estimatedItemHeight;
430
- this.lastScrollTop = scrollTop;
431
- const newVisibleItems = [];
432
- for (let i = 0; i < this.posts.length; i++) {
433
- const post = this.posts[i];
434
- const position = this.positions.get(post.id);
435
- if (!position)
436
- continue;
437
- const isVisible = isInViewport({
438
- top: position.y,
439
- bottom: position.y + position.height,
440
- }, scrollTop, viewportHeight, overscanPx);
441
- if (isVisible) {
442
- newVisibleItems.push({
443
- index: i,
444
- post,
445
- position,
446
- isVisible: true,
447
- });
448
- }
449
- }
450
- // Check if visible items changed
451
- const hasChanged = this.hasVisibleItemsChanged(newVisibleItems);
452
- if (hasChanged) {
453
- this.visibleItems = newVisibleItems;
454
- this.onVisibleItemsChange?.(newVisibleItems);
455
- }
456
- return this.visibleItems;
457
- }
458
- /**
459
- * Check if visible items have changed
460
- */
461
- hasVisibleItemsChanged(newItems) {
462
- if (newItems.length !== this.visibleItems.length) {
463
- return true;
464
- }
465
- for (let i = 0; i < newItems.length; i++) {
466
- if (newItems[i].post.id !== this.visibleItems[i].post.id) {
467
- return true;
468
- }
469
- }
470
- return false;
471
- }
472
- /**
473
- * Update posts and positions
474
- */
475
- update(posts, positions) {
476
- this.posts = posts;
477
- this.positions = positions;
478
- this.calculateVisibleItems();
479
- }
480
- /**
481
- * Get visible items
482
- */
483
- getVisibleItems() {
484
- return this.visibleItems;
485
- }
486
- /**
487
- * Get all items (for non-virtualized mode)
488
- */
489
- getAllItems() {
490
- return this.posts.map((post, index) => ({
491
- index,
492
- post,
493
- position: this.positions.get(post.id),
494
- isVisible: true,
495
- }));
496
- }
497
- /**
498
- * Check if scrolling
499
- */
500
- getIsScrolling() {
501
- return this.isScrolling;
502
- }
503
- /**
504
- * Get scroll direction
505
- */
506
- getScrollDirection() {
507
- const { scrollTop } = getScrollPosition(this.options.scrollContainer);
508
- if (scrollTop > this.lastScrollTop) {
509
- return 'down';
510
- }
511
- else if (scrollTop < this.lastScrollTop) {
512
- return 'up';
513
- }
514
- return 'none';
515
- }
516
- /**
517
- * Check if near bottom (for infinite scroll)
518
- */
519
- isNearBottom(threshold = 500) {
520
- const { scrollTop, viewportHeight } = getScrollPosition(this.options.scrollContainer);
521
- // Get container height from positions
522
- let maxBottom = 0;
523
- for (const position of this.positions.values()) {
524
- maxBottom = Math.max(maxBottom, position.y + position.height);
525
- }
526
- return scrollTop + viewportHeight >= maxBottom - threshold;
527
- }
528
- /**
529
- * Scroll to item
530
- */
531
- scrollToItem(id, behavior = 'smooth') {
532
- const position = this.positions.get(id);
533
- if (!position)
534
- return;
535
- const container = this.options.scrollContainer ?? window;
536
- if (container === window) {
537
- window.scrollTo({
538
- top: position.y,
539
- behavior,
540
- });
541
- }
542
- else {
543
- container.scrollTo({
544
- top: position.y,
545
- behavior,
546
- });
547
- }
548
- }
549
- /**
550
- * Get render range for optimization
551
- */
552
- getRenderRange() {
553
- if (this.visibleItems.length === 0) {
554
- return { start: 0, end: 0 };
555
- }
556
- const indices = this.visibleItems.map(item => item.index);
557
- return {
558
- start: Math.min(...indices),
559
- end: Math.max(...indices) + 1,
560
- };
561
- }
562
- }
563
- /**
564
- * Create a new virtualization engine instance
565
- */
566
- function createVirtualizationEngine(options) {
567
- return new VirtualizationEngine(options);
568
- }
569
-
570
- /**
571
- * Social Masonry - Card Renderer
572
- * Renders social media post cards with proper styling
573
- */
574
- const DEFAULT_OPTIONS$1 = {
575
- variant: 'default',
576
- theme: 'auto',
577
- borderRadius: 12,
578
- showPlatformIcon: true,
579
- showAuthor: true,
580
- showMetrics: true,
581
- showTimestamp: true,
582
- formatDate: (date) => formatRelativeTime(date),
583
- formatNumber: formatNumber,
584
- className: '',
585
- hoverEffect: true,
586
- imageLoading: 'lazy',
587
- 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',
588
- };
589
- class CardRenderer {
590
- constructor(options = {}) {
591
- this.options = {
592
- ...DEFAULT_OPTIONS$1,
593
- onPostClick: undefined,
594
- onAuthorClick: undefined,
595
- onMediaClick: undefined,
596
- onImageError: undefined,
597
- ...options,
598
- };
599
- }
600
- /**
601
- * Render a single card
602
- */
603
- render(post, position) {
604
- const card = document.createElement('div');
605
- card.className = this.getCardClasses(post);
606
- card.setAttribute('data-post-id', post.id);
607
- card.setAttribute('data-platform', post.platform);
608
- // Apply position styles
609
- Object.assign(card.style, {
610
- position: 'absolute',
611
- left: `${position.x}px`,
612
- top: `${position.y}px`,
613
- width: `${position.width}px`,
614
- borderRadius: typeof this.options.borderRadius === 'number'
615
- ? `${this.options.borderRadius}px`
616
- : this.options.borderRadius,
617
- });
618
- // Build card content
619
- card.innerHTML = this.buildCardHTML(post);
620
- // Attach event listeners
621
- this.attachEventListeners(card, post);
622
- return card;
623
- }
624
- /**
625
- * Get CSS classes for card
626
- */
627
- getCardClasses(post) {
628
- const classes = [
629
- 'sm-card',
630
- `sm-card--${post.platform}`,
631
- `sm-card--${this.options.variant}`,
632
- `sm-card--${this.options.theme}`,
633
- ];
634
- if (this.options.hoverEffect) {
635
- classes.push('sm-card--hover');
636
- }
637
- if (this.options.className) {
638
- classes.push(this.options.className);
639
- }
640
- return classes.join(' ');
641
- }
642
- /**
643
- * Build card HTML
644
- */
645
- buildCardHTML(post) {
646
- const parts = [];
647
- // Platform icon
648
- if (this.options.showPlatformIcon) {
649
- parts.push(this.renderPlatformIcon(post.platform));
650
- }
651
- // Header with author
652
- if (this.options.showAuthor) {
653
- parts.push(this.renderHeader(post));
654
- }
655
- // Content
656
- parts.push(this.renderContent(post));
657
- // Media
658
- parts.push(this.renderMedia(post));
659
- // Footer with metrics
660
- if (this.options.showMetrics || this.options.showTimestamp) {
661
- parts.push(this.renderFooter(post));
662
- }
663
- return parts.join('');
664
- }
665
- /**
666
- * Render platform icon
667
- */
668
- renderPlatformIcon(platform) {
669
- const icons = {
670
- 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>`,
671
- 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>`,
672
- };
673
- return `<div class="sm-card__platform-icon">${icons[platform] || ''}</div>`;
674
- }
675
- /**
676
- * Render card header
677
- */
678
- renderHeader(post) {
679
- const author = post.author;
680
- const avatarUrl = author.avatarUrl || this.options.fallbackImage;
681
- const verifiedBadge = author.verified
682
- ? `<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>`
683
- : '';
684
- return `
685
- <div class="sm-card__header">
686
- <img
687
- src="${avatarUrl}"
688
- alt="${author.displayName || author.username}"
689
- class="sm-card__avatar"
690
- loading="${this.options.imageLoading}"
691
- onerror="this.src='${this.options.fallbackImage}'"
692
- />
693
- <div class="sm-card__author">
694
- <span class="sm-card__author-name">
695
- ${author.displayName || author.username}
696
- ${verifiedBadge}
697
- </span>
698
- <span class="sm-card__author-handle">@${author.username}</span>
699
- </div>
700
- </div>
701
- `;
702
- }
703
- /**
704
- * Render card content
705
- */
706
- renderContent(post) {
707
- if (isTwitterPost(post)) {
708
- return this.renderTwitterContent(post);
709
- }
710
- else if (isInstagramPost(post)) {
711
- return this.renderInstagramContent(post);
712
- }
713
- return '';
714
- }
715
- /**
716
- * Render Twitter content
717
- */
718
- renderTwitterContent(post) {
719
- const text = post.content.html || this.linkifyText(post.content.text);
720
- let quotedPost = '';
721
- if (post.quotedPost) {
722
- quotedPost = `
723
- <div class="sm-card__quoted">
724
- <div class="sm-card__quoted-header">
725
- <span class="sm-card__quoted-name">${post.quotedPost.author.displayName}</span>
726
- <span class="sm-card__quoted-handle">@${post.quotedPost.author.username}</span>
727
- </div>
728
- <div class="sm-card__quoted-text">${post.quotedPost.content.text}</div>
729
- </div>
730
- `;
731
- }
732
- return `
733
- <div class="sm-card__content">
734
- <p class="sm-card__text">${text}</p>
735
- ${quotedPost}
736
- </div>
737
- `;
738
- }
739
- /**
740
- * Render Instagram content
741
- */
742
- renderInstagramContent(post) {
743
- if (!post.content.caption)
744
- return '';
745
- // Truncate caption if too long
746
- const maxLength = 150;
747
- let caption = post.content.caption;
748
- let showMore = false;
749
- if (caption.length > maxLength) {
750
- caption = caption.substring(0, maxLength);
751
- showMore = true;
752
- }
753
- return `
754
- <div class="sm-card__content">
755
- <p class="sm-card__caption">
756
- ${this.linkifyText(caption)}${showMore ? '<span class="sm-card__more">... more</span>' : ''}
757
- </p>
758
- </div>
759
- `;
760
- }
761
- /**
762
- * Render media
763
- */
764
- renderMedia(post) {
765
- if (isTwitterPost(post)) {
766
- return this.renderTwitterMedia(post);
767
- }
768
- else if (isInstagramPost(post)) {
769
- return this.renderInstagramMedia(post);
770
- }
771
- return '';
772
- }
773
- /**
774
- * Render Twitter media
775
- */
776
- renderTwitterMedia(post) {
777
- if (!post.media?.length)
778
- return '';
779
- const mediaCount = post.media.length;
780
- const gridClass = mediaCount > 1 ? `sm-card__media-grid sm-card__media-grid--${Math.min(mediaCount, 4)}` : '';
781
- const mediaItems = post.media.slice(0, 4).map((media, index) => {
782
- if (media.type === 'video' || media.type === 'gif') {
783
- return `
784
- <div class="sm-card__media-item sm-card__media-item--video" data-index="${index}">
785
- <img
786
- src="${media.thumbnailUrl || media.url}"
787
- alt="Video thumbnail"
788
- loading="${this.options.imageLoading}"
789
- onerror="this.src='${this.options.fallbackImage}'"
790
- />
791
- <div class="sm-card__play-button">
792
- <svg viewBox="0 0 24 24"><path fill="currentColor" d="M8 5v14l11-7z"/></svg>
793
- </div>
794
- </div>
795
- `;
796
- }
797
- return `
798
- <div class="sm-card__media-item" data-index="${index}">
799
- <img
800
- src="${media.url}"
801
- alt="Post media"
802
- loading="${this.options.imageLoading}"
803
- onerror="this.src='${this.options.fallbackImage}'"
804
- />
805
- </div>
806
- `;
807
- }).join('');
808
- return `<div class="sm-card__media ${gridClass}">${mediaItems}</div>`;
809
- }
810
- /**
811
- * Render Instagram media
812
- */
813
- renderInstagramMedia(post) {
814
- const media = post.media;
815
- const aspectRatio = media.aspectRatio || 1;
816
- if (media.type === 'carousel' && media.carouselItems?.length) {
817
- return `
818
- <div class="sm-card__media sm-card__carousel" style="aspect-ratio: ${aspectRatio}">
819
- <img
820
- src="${media.carouselItems[0].url}"
821
- alt="Post media"
822
- loading="${this.options.imageLoading}"
823
- onerror="this.src='${this.options.fallbackImage}'"
824
- />
825
- <div class="sm-card__carousel-indicator">
826
- <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>
827
- </div>
828
- </div>
829
- `;
830
- }
831
- if (media.type === 'video') {
832
- return `
833
- <div class="sm-card__media sm-card__media--video" style="aspect-ratio: ${aspectRatio}">
834
- <img
835
- src="${media.thumbnailUrl || media.url}"
836
- alt="Video thumbnail"
837
- loading="${this.options.imageLoading}"
838
- onerror="this.src='${this.options.fallbackImage}'"
839
- />
840
- <div class="sm-card__play-button">
841
- <svg viewBox="0 0 24 24"><path fill="currentColor" d="M8 5v14l11-7z"/></svg>
842
- </div>
843
- </div>
844
- `;
845
- }
846
- return `
847
- <div class="sm-card__media" style="aspect-ratio: ${aspectRatio}">
848
- <img
849
- src="${media.url}"
850
- alt="Post media"
851
- loading="${this.options.imageLoading}"
852
- onerror="this.src='${this.options.fallbackImage}'"
853
- />
854
- </div>
855
- `;
856
- }
857
- /**
858
- * Render footer with metrics
859
- */
860
- renderFooter(post) {
861
- const parts = [];
862
- if (this.options.showMetrics && post.metrics) {
863
- parts.push(this.renderMetrics(post));
864
- }
865
- if (this.options.showTimestamp) {
866
- const date = post.createdAt instanceof Date
867
- ? post.createdAt
868
- : new Date(post.createdAt);
869
- parts.push(`
870
- <time class="sm-card__timestamp" datetime="${date.toISOString()}">
871
- ${this.options.formatDate(date)}
872
- </time>
873
- `);
874
- }
875
- return `<div class="sm-card__footer">${parts.join('')}</div>`;
876
- }
877
- /**
878
- * Render metrics
879
- */
880
- renderMetrics(post) {
881
- if (!post.metrics)
882
- return '';
883
- const formatNum = this.options.formatNumber;
884
- if (isTwitterPost(post)) {
885
- const metrics = post.metrics;
886
- return `
887
- <div class="sm-card__metrics">
888
- ${metrics.replies !== undefined ? `
889
- <span class="sm-card__metric">
890
- <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>
891
- ${formatNum(metrics.replies)}
892
- </span>
893
- ` : ''}
894
- ${metrics.retweets !== undefined ? `
895
- <span class="sm-card__metric">
896
- <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>
897
- ${formatNum(metrics.retweets)}
898
- </span>
899
- ` : ''}
900
- ${metrics.likes !== undefined ? `
901
- <span class="sm-card__metric">
902
- <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>
903
- ${formatNum(metrics.likes)}
904
- </span>
905
- ` : ''}
906
- ${metrics.views !== undefined ? `
907
- <span class="sm-card__metric">
908
- <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>
909
- ${formatNum(metrics.views)}
910
- </span>
911
- ` : ''}
912
- </div>
913
- `;
914
- }
915
- if (isInstagramPost(post)) {
916
- const metrics = post.metrics;
917
- return `
918
- <div class="sm-card__metrics">
919
- ${metrics.likes !== undefined ? `
920
- <span class="sm-card__metric">
921
- <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>
922
- ${formatNum(metrics.likes)}
923
- </span>
924
- ` : ''}
925
- ${metrics.comments !== undefined ? `
926
- <span class="sm-card__metric">
927
- <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>
928
- ${formatNum(metrics.comments)}
929
- </span>
930
- ` : ''}
931
- </div>
932
- `;
933
- }
934
- return '';
935
- }
936
- /**
937
- * Linkify text (URLs, mentions, hashtags)
938
- */
939
- linkifyText(text) {
940
- // Escape HTML
941
- let result = text
942
- .replace(/&/g, '&amp;')
943
- .replace(/</g, '&lt;')
944
- .replace(/>/g, '&gt;');
945
- // URLs
946
- result = result.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer" class="sm-card__link">$1</a>');
947
- // Mentions
948
- result = result.replace(/@(\w+)/g, '<a href="#" class="sm-card__mention" data-username="$1">@$1</a>');
949
- // Hashtags
950
- result = result.replace(/#(\w+)/g, '<a href="#" class="sm-card__hashtag" data-tag="$1">#$1</a>');
951
- return result;
952
- }
953
- /**
954
- * Attach event listeners
955
- */
956
- attachEventListeners(card, post) {
957
- // Post click
958
- if (this.options.onPostClick) {
959
- card.addEventListener('click', (e) => {
960
- // Don't fire for links, buttons, etc.
961
- const target = e.target;
962
- if (target.tagName === 'A' || target.closest('a'))
963
- return;
964
- this.options.onPostClick?.(post, e);
965
- });
966
- card.style.cursor = 'pointer';
967
- }
968
- // Author click
969
- if (this.options.onAuthorClick) {
970
- const header = card.querySelector('.sm-card__header');
971
- if (header) {
972
- header.addEventListener('click', (e) => {
973
- e.stopPropagation();
974
- this.options.onAuthorClick?.(post, e);
975
- });
976
- header.style.cursor = 'pointer';
977
- }
978
- }
979
- // Media click
980
- if (this.options.onMediaClick) {
981
- const mediaItems = card.querySelectorAll('.sm-card__media-item');
982
- mediaItems.forEach((item) => {
983
- item.addEventListener('click', (e) => {
984
- e.stopPropagation();
985
- const index = parseInt(item.dataset.index || '0', 10);
986
- this.options.onMediaClick?.(post, index, e);
987
- });
988
- item.style.cursor = 'pointer';
989
- });
990
- }
991
- // Image error handling
992
- if (this.options.onImageError) {
993
- const images = card.querySelectorAll('img');
994
- images.forEach((img) => {
995
- img.addEventListener('error', () => {
996
- this.options.onImageError?.(post, new Error('Image failed to load'));
997
- });
998
- });
999
- }
1000
- }
1001
- /**
1002
- * Update card position
1003
- */
1004
- updatePosition(card, position) {
1005
- Object.assign(card.style, {
1006
- left: `${position.x}px`,
1007
- top: `${position.y}px`,
1008
- width: `${position.width}px`,
1009
- });
1010
- }
1011
- /**
1012
- * Update options
1013
- */
1014
- setOptions(options) {
1015
- this.options = { ...this.options, ...options };
1016
- }
1017
- }
1018
- /**
1019
- * Create a new card renderer instance
1020
- */
1021
- function createCardRenderer(options) {
1022
- return new CardRenderer(options);
1023
- }
1024
-
1025
- /**
1026
- * Social Masonry - Main Class
1027
- * Beautiful masonry layout for X (Twitter) and Instagram embeds
1028
- */
1029
- const DEFAULT_OPTIONS = {
1030
- // Layout
1031
- gap: 16,
1032
- columns: defaultColumnConfig,
1033
- defaultColumns: 3,
1034
- padding: 0,
1035
- animationDuration: 300,
1036
- animate: true,
1037
- easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
1038
- // Card
1039
- variant: 'default',
1040
- theme: 'auto',
1041
- borderRadius: 12,
1042
- showPlatformIcon: true,
1043
- showAuthor: true,
1044
- showMetrics: true,
1045
- showTimestamp: true,
1046
- hoverEffect: true,
1047
- imageLoading: 'lazy',
1048
- // Virtualization
1049
- virtualization: {
1050
- enabled: false,
1051
- overscan: 3,
1052
- estimatedItemHeight: 400,
1053
- scrollContainer: null,
1054
- },
1055
- // Infinite scroll
1056
- loadMoreThreshold: 500,
1057
- showLoading: true,
1058
- // Debug
1059
- debug: false,
1060
- };
1061
- class SocialMasonry {
1062
- constructor(options) {
1063
- this.posts = [];
1064
- this.virtualizationEngine = null;
1065
- this.cards = new Map();
1066
- this.itemHeights = new Map();
1067
- this.resizeObserver = null;
1068
- this.isLoading = false;
1069
- if (!isBrowser) {
1070
- throw new Error('SocialMasonry requires a browser environment');
1071
- }
1072
- this.instanceId = generateId();
1073
- this.options = { ...DEFAULT_OPTIONS, ...options };
1074
- // Get container element
1075
- const container = typeof options.container === 'string'
1076
- ? document.querySelector(options.container)
1077
- : options.container;
1078
- if (!container) {
1079
- throw new Error('Container element not found');
1080
- }
1081
- this.container = container;
1082
- this.posts = options.posts || [];
1083
- // Initialize layout engine
1084
- this.layoutEngine = new LayoutEngine({
1085
- ...this.options,
1086
- containerWidth: this.container.clientWidth,
1087
- itemHeights: this.itemHeights,
1088
- });
1089
- // Initialize card renderer
1090
- this.cardRenderer = new CardRenderer({
1091
- ...this.options,
1092
- });
1093
- // Initialize virtualization if enabled
1094
- if (this.options.virtualization?.enabled) {
1095
- this.initVirtualization();
1096
- }
1097
- // Setup
1098
- this.setupContainer();
1099
- this.setupResizeObserver();
1100
- this.render();
1101
- if (this.options.debug) {
1102
- console.log('[SocialMasonry] Initialized', {
1103
- instanceId: this.instanceId,
1104
- posts: this.posts.length,
1105
- containerWidth: this.container.clientWidth,
1106
- });
1107
- }
1108
- }
1109
- /**
1110
- * Setup container styles
1111
- */
1112
- setupContainer() {
1113
- this.container.classList.add('sm-container');
1114
- this.container.setAttribute('data-sm-instance', this.instanceId);
1115
- Object.assign(this.container.style, {
1116
- position: 'relative',
1117
- width: '100%',
1118
- });
1119
- }
1120
- /**
1121
- * Setup resize observer
1122
- */
1123
- setupResizeObserver() {
1124
- if (!supportsResizeObserver) {
1125
- // Fallback to window resize
1126
- window.addEventListener('resize', debounce(() => this.handleResize(), 150));
1127
- return;
1128
- }
1129
- this.resizeObserver = new ResizeObserver(debounce((entries) => {
1130
- for (const entry of entries) {
1131
- if (entry.target === this.container) {
1132
- this.handleResize();
1133
- }
1134
- }
1135
- }, 150));
1136
- this.resizeObserver.observe(this.container);
1137
- }
1138
- /**
1139
- * Handle container resize
1140
- */
1141
- handleResize() {
1142
- const newWidth = this.container.clientWidth;
1143
- this.layoutEngine.setContainerWidth(newWidth);
1144
- this.recalculateLayout();
1145
- if (this.options.debug) {
1146
- console.log('[SocialMasonry] Resize', { width: newWidth });
1147
- }
1148
- }
1149
- /**
1150
- * Initialize virtualization
1151
- */
1152
- initVirtualization() {
1153
- const state = this.layoutEngine.getState();
1154
- this.virtualizationEngine = new VirtualizationEngine({
1155
- ...this.options.virtualization,
1156
- posts: this.posts,
1157
- positions: state.positions,
1158
- onVisibleItemsChange: (items) => this.handleVisibleItemsChange(items),
1159
- });
1160
- this.virtualizationEngine.init();
1161
- // Setup infinite scroll
1162
- if (this.options.onLoadMore) {
1163
- this.setupInfiniteScroll();
1164
- }
1165
- }
1166
- /**
1167
- * Handle visible items change (for virtualization)
1168
- */
1169
- handleVisibleItemsChange(items) {
1170
- const visibleIds = new Set(items.map(item => item.post.id));
1171
- // Remove cards that are no longer visible
1172
- for (const [id, card] of this.cards) {
1173
- if (!visibleIds.has(id)) {
1174
- card.remove();
1175
- this.cards.delete(id);
1176
- }
1177
- }
1178
- // Add new visible cards
1179
- for (const item of items) {
1180
- if (!this.cards.has(item.post.id)) {
1181
- this.renderCard(item.post, item.position);
1182
- }
1183
- }
1184
- }
1185
- /**
1186
- * Setup infinite scroll
1187
- */
1188
- setupInfiniteScroll() {
1189
- const checkLoadMore = () => {
1190
- if (!this.isLoading &&
1191
- this.virtualizationEngine?.isNearBottom(this.options.loadMoreThreshold || 500)) {
1192
- this.loadMore();
1193
- }
1194
- };
1195
- const scrollContainer = this.options.virtualization?.scrollContainer ?? window;
1196
- scrollContainer.addEventListener('scroll', checkLoadMore, { passive: true });
1197
- }
1198
- /**
1199
- * Load more posts
1200
- */
1201
- async loadMore() {
1202
- if (this.isLoading || !this.options.onLoadMore)
1203
- return;
1204
- this.isLoading = true;
1205
- this.showLoadingIndicator();
1206
- try {
1207
- await this.options.onLoadMore();
1208
- }
1209
- finally {
1210
- this.isLoading = false;
1211
- this.hideLoadingIndicator();
1212
- }
1213
- }
1214
- /**
1215
- * Show loading indicator
1216
- */
1217
- showLoadingIndicator() {
1218
- if (!this.options.showLoading)
1219
- return;
1220
- let loader = this.container.querySelector('.sm-loader');
1221
- if (!loader) {
1222
- loader = document.createElement('div');
1223
- loader.className = 'sm-loader';
1224
- if (this.options.loadingElement) {
1225
- if (typeof this.options.loadingElement === 'string') {
1226
- loader.innerHTML = this.options.loadingElement;
1227
- }
1228
- else {
1229
- loader.appendChild(this.options.loadingElement.cloneNode(true));
1230
- }
1231
- }
1232
- else {
1233
- loader.innerHTML = `
1234
- <div class="sm-loader__spinner">
1235
- <div></div><div></div><div></div>
1236
- </div>
1237
- `;
1238
- }
1239
- this.container.appendChild(loader);
1240
- }
1241
- loader.style.display = 'flex';
1242
- }
1243
- /**
1244
- * Hide loading indicator
1245
- */
1246
- hideLoadingIndicator() {
1247
- const loader = this.container.querySelector('.sm-loader');
1248
- if (loader) {
1249
- loader.style.display = 'none';
1250
- }
1251
- }
1252
- /**
1253
- * Render all posts
1254
- */
1255
- render() {
1256
- // Calculate layout
1257
- const state = this.layoutEngine.calculate(this.posts);
1258
- // Update container height
1259
- this.container.style.height = `${state.containerHeight}px`;
1260
- // Apply CSS variables
1261
- const cssVars = this.layoutEngine.getCSSVariables();
1262
- for (const [key, value] of Object.entries(cssVars)) {
1263
- this.container.style.setProperty(key, value);
1264
- }
1265
- // Render cards
1266
- if (this.virtualizationEngine) {
1267
- // Virtualized rendering
1268
- this.virtualizationEngine.update(this.posts, state.positions);
1269
- const visibleItems = this.virtualizationEngine.calculateVisibleItems();
1270
- this.handleVisibleItemsChange(visibleItems);
1271
- }
1272
- else {
1273
- // Full rendering
1274
- for (const post of this.posts) {
1275
- const position = state.positions.get(post.id);
1276
- if (position) {
1277
- this.renderCard(post, position);
1278
- }
1279
- }
1280
- }
1281
- // Show empty state if no posts
1282
- if (this.posts.length === 0) {
1283
- this.showEmptyState();
1284
- }
1285
- else {
1286
- this.hideEmptyState();
1287
- }
1288
- // Callback
1289
- this.options.onLayoutComplete?.(Array.from(state.positions.values()));
1290
- }
1291
- /**
1292
- * Render a single card
1293
- */
1294
- renderCard(post, position) {
1295
- let card = this.cards.get(post.id);
1296
- if (card) {
1297
- // Update existing card position
1298
- this.cardRenderer.updatePosition(card, position);
1299
- }
1300
- else {
1301
- // Create new card
1302
- card = this.cardRenderer.render(post, position);
1303
- this.container.appendChild(card);
1304
- this.cards.set(post.id, card);
1305
- // Measure actual height after render
1306
- requestAnimationFrame(() => {
1307
- if (card) {
1308
- const actualHeight = card.offsetHeight;
1309
- if (actualHeight !== position.height) {
1310
- this.itemHeights.set(post.id, actualHeight);
1311
- this.recalculateLayout();
1312
- }
1313
- }
1314
- });
1315
- }
1316
- }
1317
- /**
1318
- * Recalculate layout
1319
- */
1320
- recalculateLayout() {
1321
- const state = this.layoutEngine.calculate(this.posts);
1322
- this.container.style.height = `${state.containerHeight}px`;
1323
- // Update virtualization
1324
- if (this.virtualizationEngine) {
1325
- this.virtualizationEngine.update(this.posts, state.positions);
1326
- }
1327
- // Update card positions with animation
1328
- for (const [id, card] of this.cards) {
1329
- const position = state.positions.get(id);
1330
- if (position) {
1331
- if (this.options.animate) {
1332
- 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}`;
1333
- }
1334
- this.cardRenderer.updatePosition(card, position);
1335
- }
1336
- }
1337
- this.options.onLayoutComplete?.(Array.from(state.positions.values()));
1338
- }
1339
- /**
1340
- * Show empty state
1341
- */
1342
- showEmptyState() {
1343
- let empty = this.container.querySelector('.sm-empty');
1344
- if (!empty) {
1345
- empty = document.createElement('div');
1346
- empty.className = 'sm-empty';
1347
- if (this.options.emptyElement) {
1348
- if (typeof this.options.emptyElement === 'string') {
1349
- empty.innerHTML = this.options.emptyElement;
1350
- }
1351
- else {
1352
- empty.appendChild(this.options.emptyElement.cloneNode(true));
1353
- }
1354
- }
1355
- else {
1356
- empty.textContent = this.options.emptyMessage || 'No posts to display';
1357
- }
1358
- this.container.appendChild(empty);
1359
- }
1360
- empty.style.display = 'flex';
1361
- }
1362
- /**
1363
- * Hide empty state
1364
- */
1365
- hideEmptyState() {
1366
- const empty = this.container.querySelector('.sm-empty');
1367
- if (empty) {
1368
- empty.style.display = 'none';
1369
- }
1370
- }
1371
- // ============================================
1372
- // Public API
1373
- // ============================================
1374
- /**
1375
- * Add posts
1376
- */
1377
- addPosts(posts) {
1378
- this.posts = [...this.posts, ...posts];
1379
- this.render();
1380
- if (this.options.debug) {
1381
- console.log('[SocialMasonry] Added posts', { count: posts.length, total: this.posts.length });
1382
- }
1383
- }
1384
- /**
1385
- * Set posts (replace all)
1386
- */
1387
- setPosts(posts) {
1388
- // Clear existing cards
1389
- for (const card of this.cards.values()) {
1390
- card.remove();
1391
- }
1392
- this.cards.clear();
1393
- this.itemHeights.clear();
1394
- this.posts = posts;
1395
- this.render();
1396
- if (this.options.debug) {
1397
- console.log('[SocialMasonry] Set posts', { count: posts.length });
1398
- }
1399
- }
1400
- /**
1401
- * Remove post
1402
- */
1403
- removePost(id) {
1404
- const card = this.cards.get(id);
1405
- if (card) {
1406
- card.remove();
1407
- this.cards.delete(id);
1408
- }
1409
- this.itemHeights.delete(id);
1410
- this.posts = this.posts.filter(p => p.id !== id);
1411
- this.recalculateLayout();
1412
- if (this.options.debug) {
1413
- console.log('[SocialMasonry] Removed post', { id });
1414
- }
1415
- }
1416
- /**
1417
- * Update options
1418
- */
1419
- setOptions(options) {
1420
- this.options = { ...this.options, ...options };
1421
- this.cardRenderer.setOptions(options);
1422
- this.recalculateLayout();
1423
- }
1424
- /**
1425
- * Get layout state
1426
- */
1427
- getLayoutState() {
1428
- return this.layoutEngine.getState();
1429
- }
1430
- /**
1431
- * Get posts
1432
- */
1433
- getPosts() {
1434
- return [...this.posts];
1435
- }
1436
- /**
1437
- * Scroll to post
1438
- */
1439
- scrollToPost(id, behavior = 'smooth') {
1440
- if (this.virtualizationEngine) {
1441
- this.virtualizationEngine.scrollToItem(id, behavior);
1442
- }
1443
- else {
1444
- const position = this.layoutEngine.getPosition(id);
1445
- if (position) {
1446
- window.scrollTo({
1447
- top: position.y,
1448
- behavior,
1449
- });
1450
- }
1451
- }
1452
- }
1453
- /**
1454
- * Refresh layout
1455
- */
1456
- refresh() {
1457
- this.itemHeights.clear();
1458
- this.recalculateLayout();
1459
- }
1460
- /**
1461
- * Destroy instance
1462
- */
1463
- destroy() {
1464
- // Remove cards
1465
- for (const card of this.cards.values()) {
1466
- card.remove();
1467
- }
1468
- this.cards.clear();
1469
- // Remove observers
1470
- this.resizeObserver?.disconnect();
1471
- // Destroy virtualization
1472
- this.virtualizationEngine?.destroy();
1473
- // Clean up container
1474
- this.container.classList.remove('sm-container');
1475
- this.container.removeAttribute('data-sm-instance');
1476
- this.container.style.cssText = '';
1477
- // Remove internal elements
1478
- const loader = this.container.querySelector('.sm-loader');
1479
- const empty = this.container.querySelector('.sm-empty');
1480
- loader?.remove();
1481
- empty?.remove();
1482
- if (this.options.debug) {
1483
- console.log('[SocialMasonry] Destroyed', { instanceId: this.instanceId });
1484
- }
1485
- }
1486
- }
1487
- /**
1488
- * Create a new SocialMasonry instance
1489
- */
1490
- function createSocialMasonry(options) {
1491
- return new SocialMasonry(options);
1492
- }
1493
-
1494
- export { CardRenderer, LayoutEngine, SocialMasonry, VirtualizationEngine, createCardRenderer, createLayoutEngine, createSocialMasonry, createVirtualizationEngine, SocialMasonry as default, defaultColumnConfig, formatNumber, formatRelativeTime, isInstagramPost, isTwitterPost };
233
+ export { DEFAULT_INSTAGRAM_HEIGHT, DEFAULT_TWITTER_HEIGHT, LayoutEngine, createLayoutEngine, defaultColumnConfig, detectPlatform, extractInstagramId, extractTweetId, generatePostId, getColumnCount, getInstagramEmbedUrl, getTwitterEmbedUrl };
1495
234
  //# sourceMappingURL=index.esm.js.map