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/README.md +97 -308
- package/dist/index.d.ts +62 -437
- package/dist/index.esm.js +93 -1354
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +101 -1366
- package/dist/index.js.map +1 -1
- package/dist/react/index.d.ts +35 -141
- package/dist/react/index.esm.js +280 -651
- package/dist/react/index.esm.js.map +1 -1
- package/dist/react/index.js +278 -649
- package/dist/react/index.js.map +1 -1
- package/package.json +1 -1
package/dist/react/index.esm.js
CHANGED
|
@@ -1,47 +1,57 @@
|
|
|
1
|
-
import { jsxs, jsx
|
|
2
|
-
import { forwardRef, useRef, useState,
|
|
1
|
+
import { jsxs, jsx } from 'react/jsx-runtime';
|
|
2
|
+
import { forwardRef, useRef, useState, useEffect, useImperativeHandle, useCallback } from 'react';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Social Masonry - Utility Functions
|
|
6
6
|
*/
|
|
7
|
+
// ============================================
|
|
8
|
+
// URL Parsing Utilities
|
|
9
|
+
// ============================================
|
|
7
10
|
/**
|
|
8
|
-
*
|
|
11
|
+
* Extract tweet ID from Twitter/X URL
|
|
9
12
|
*/
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
clearTimeout(timeoutId);
|
|
16
|
-
}
|
|
17
|
-
timeoutId = setTimeout(() => {
|
|
18
|
-
fn.apply(this, args);
|
|
19
|
-
timeoutId = null;
|
|
20
|
-
}, delay);
|
|
21
|
-
};
|
|
13
|
+
function extractTweetId(url) {
|
|
14
|
+
if (!url)
|
|
15
|
+
return null;
|
|
16
|
+
const match = url.match(/(?:twitter\.com|x\.com)\/\w+\/status\/(\d+)/);
|
|
17
|
+
return match ? match[1] : null;
|
|
22
18
|
}
|
|
23
19
|
/**
|
|
24
|
-
*
|
|
20
|
+
* Extract post ID from Instagram URL
|
|
25
21
|
*/
|
|
26
|
-
function
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
22
|
+
function extractInstagramId(url) {
|
|
23
|
+
if (!url)
|
|
24
|
+
return null;
|
|
25
|
+
const match = url.match(/instagram\.com\/(?:p|reel)\/([A-Za-z0-9_-]+)/);
|
|
26
|
+
return match ? match[1] : null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Generate unique ID from post URL
|
|
30
|
+
*/
|
|
31
|
+
function generatePostId(post) {
|
|
32
|
+
if (post.id)
|
|
33
|
+
return post.id;
|
|
34
|
+
if (post.platform === 'twitter') {
|
|
35
|
+
const tweetId = extractTweetId(post.url);
|
|
36
|
+
return tweetId ? `tw-${tweetId}` : `tw-${hashString(post.url)}`;
|
|
37
|
+
}
|
|
38
|
+
if (post.platform === 'instagram') {
|
|
39
|
+
const igId = extractInstagramId(post.url);
|
|
40
|
+
return igId ? `ig-${igId}` : `ig-${hashString(post.url)}`;
|
|
41
|
+
}
|
|
42
|
+
return `post-${hashString(post.url)}`;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Simple string hash function
|
|
46
|
+
*/
|
|
47
|
+
function hashString(str) {
|
|
48
|
+
let hash = 0;
|
|
49
|
+
for (let i = 0; i < str.length; i++) {
|
|
50
|
+
const char = str.charCodeAt(i);
|
|
51
|
+
hash = ((hash << 5) - hash) + char;
|
|
52
|
+
hash = hash & hash;
|
|
53
|
+
}
|
|
54
|
+
return Math.abs(hash).toString(36);
|
|
45
55
|
}
|
|
46
56
|
/**
|
|
47
57
|
* Get number of columns based on viewport width
|
|
@@ -69,660 +79,279 @@ const defaultColumnConfig = [
|
|
|
69
79
|
{ columns: 2, minWidth: 600 },
|
|
70
80
|
{ columns: 1, minWidth: 0 },
|
|
71
81
|
];
|
|
72
|
-
/**
|
|
73
|
-
* Format number with abbreviations (1K, 1M, etc.)
|
|
74
|
-
*/
|
|
75
|
-
function formatNumber(num) {
|
|
76
|
-
if (num >= 1000000) {
|
|
77
|
-
return `${(num / 1000000).toFixed(1).replace(/\.0$/, '')}M`;
|
|
78
|
-
}
|
|
79
|
-
if (num >= 1000) {
|
|
80
|
-
return `${(num / 1000).toFixed(1).replace(/\.0$/, '')}K`;
|
|
81
|
-
}
|
|
82
|
-
return num.toString();
|
|
83
|
-
}
|
|
84
|
-
/**
|
|
85
|
-
* Format relative time
|
|
86
|
-
*/
|
|
87
|
-
function formatRelativeTime(date) {
|
|
88
|
-
const now = new Date();
|
|
89
|
-
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
|
90
|
-
if (diffInSeconds < 60) {
|
|
91
|
-
return 'now';
|
|
92
|
-
}
|
|
93
|
-
const diffInMinutes = Math.floor(diffInSeconds / 60);
|
|
94
|
-
if (diffInMinutes < 60) {
|
|
95
|
-
return `${diffInMinutes}m`;
|
|
96
|
-
}
|
|
97
|
-
const diffInHours = Math.floor(diffInMinutes / 60);
|
|
98
|
-
if (diffInHours < 24) {
|
|
99
|
-
return `${diffInHours}h`;
|
|
100
|
-
}
|
|
101
|
-
const diffInDays = Math.floor(diffInHours / 24);
|
|
102
|
-
if (diffInDays < 7) {
|
|
103
|
-
return `${diffInDays}d`;
|
|
104
|
-
}
|
|
105
|
-
const diffInWeeks = Math.floor(diffInDays / 7);
|
|
106
|
-
if (diffInWeeks < 4) {
|
|
107
|
-
return `${diffInWeeks}w`;
|
|
108
|
-
}
|
|
109
|
-
// Format as date for older posts
|
|
110
|
-
return date.toLocaleDateString('en-US', {
|
|
111
|
-
month: 'short',
|
|
112
|
-
day: 'numeric',
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
/**
|
|
116
|
-
* Parse CSS value to pixels
|
|
117
|
-
*/
|
|
118
|
-
function parseCSSValue(value, containerWidth) {
|
|
119
|
-
if (typeof value === 'number') {
|
|
120
|
-
return value;
|
|
121
|
-
}
|
|
122
|
-
const numMatch = value.match(/^([\d.]+)(px|rem|em|%|vw)?$/);
|
|
123
|
-
if (!numMatch) {
|
|
124
|
-
return 0;
|
|
125
|
-
}
|
|
126
|
-
const num = parseFloat(numMatch[1]);
|
|
127
|
-
const unit = numMatch[2] || 'px';
|
|
128
|
-
switch (unit) {
|
|
129
|
-
case 'px':
|
|
130
|
-
return num;
|
|
131
|
-
case 'rem':
|
|
132
|
-
return num * 16; // Assume 16px base
|
|
133
|
-
case 'em':
|
|
134
|
-
return num * 16;
|
|
135
|
-
case '%':
|
|
136
|
-
return 0;
|
|
137
|
-
case 'vw':
|
|
138
|
-
return (num / 100) * window.innerWidth;
|
|
139
|
-
default:
|
|
140
|
-
return num;
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
/**
|
|
144
|
-
* Check if element is in viewport
|
|
145
|
-
*/
|
|
146
|
-
function isInViewport(element, scrollTop, viewportHeight, overscan = 0) {
|
|
147
|
-
const expandedTop = scrollTop - overscan;
|
|
148
|
-
const expandedBottom = scrollTop + viewportHeight + overscan;
|
|
149
|
-
return element.bottom >= expandedTop && element.top <= expandedBottom;
|
|
150
|
-
}
|
|
151
|
-
/**
|
|
152
|
-
* Get scroll position
|
|
153
|
-
*/
|
|
154
|
-
function getScrollPosition(scrollContainer) {
|
|
155
|
-
if (!scrollContainer || scrollContainer === window) {
|
|
156
|
-
return {
|
|
157
|
-
scrollTop: window.scrollY || document.documentElement.scrollTop,
|
|
158
|
-
viewportHeight: window.innerHeight,
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
return {
|
|
162
|
-
scrollTop: scrollContainer.scrollTop,
|
|
163
|
-
viewportHeight: scrollContainer.clientHeight,
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
/**
|
|
167
|
-
* Type guard for Twitter posts
|
|
168
|
-
*/
|
|
169
|
-
function isTwitterPost(post) {
|
|
170
|
-
return post.platform === 'twitter';
|
|
171
|
-
}
|
|
172
|
-
/**
|
|
173
|
-
* Type guard for Instagram posts
|
|
174
|
-
*/
|
|
175
|
-
function isInstagramPost(post) {
|
|
176
|
-
return post.platform === 'instagram';
|
|
177
|
-
}
|
|
178
|
-
/**
|
|
179
|
-
* Request animation frame with fallback
|
|
180
|
-
*/
|
|
181
|
-
const raf = typeof requestAnimationFrame !== 'undefined'
|
|
182
|
-
? requestAnimationFrame
|
|
183
|
-
: (callback) => setTimeout(callback, 16);
|
|
184
|
-
/**
|
|
185
|
-
* Cancel animation frame with fallback
|
|
186
|
-
*/
|
|
187
|
-
const cancelRaf = typeof cancelAnimationFrame !== 'undefined'
|
|
188
|
-
? cancelAnimationFrame
|
|
189
|
-
: (id) => clearTimeout(id);
|
|
190
82
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
};
|
|
207
|
-
this.state = {
|
|
208
|
-
positions: new Map(),
|
|
209
|
-
columnHeights: [],
|
|
210
|
-
containerHeight: 0,
|
|
211
|
-
columnWidth: 0,
|
|
212
|
-
};
|
|
213
|
-
}
|
|
214
|
-
/**
|
|
215
|
-
* Calculate layout for all posts
|
|
216
|
-
*/
|
|
217
|
-
calculate(posts) {
|
|
218
|
-
const { containerWidth, itemHeights } = this.options;
|
|
219
|
-
const gap = parseCSSValue(this.options.gap);
|
|
220
|
-
const padding = parseCSSValue(this.options.padding);
|
|
221
|
-
// Calculate column count and width
|
|
222
|
-
const columnCount = getColumnCount(this.options.columns, containerWidth);
|
|
223
|
-
const availableWidth = containerWidth - padding * 2;
|
|
224
|
-
const totalGapWidth = gap * (columnCount - 1);
|
|
225
|
-
const columnWidth = (availableWidth - totalGapWidth) / columnCount;
|
|
226
|
-
// Initialize column heights
|
|
227
|
-
const columnHeights = new Array(columnCount).fill(0);
|
|
228
|
-
const positions = new Map();
|
|
229
|
-
// Place each item in the shortest column
|
|
230
|
-
for (const post of posts) {
|
|
231
|
-
const itemHeight = itemHeights.get(post.id) ?? this.estimateHeight(post, columnWidth);
|
|
232
|
-
// Find shortest column
|
|
233
|
-
const shortestColumn = columnHeights.indexOf(Math.min(...columnHeights));
|
|
234
|
-
// Calculate position
|
|
235
|
-
const x = padding + shortestColumn * (columnWidth + gap);
|
|
236
|
-
const y = columnHeights[shortestColumn];
|
|
237
|
-
positions.set(post.id, {
|
|
238
|
-
id: post.id,
|
|
239
|
-
x,
|
|
240
|
-
y,
|
|
241
|
-
width: columnWidth,
|
|
242
|
-
height: itemHeight,
|
|
243
|
-
column: shortestColumn,
|
|
244
|
-
});
|
|
245
|
-
// Update column height
|
|
246
|
-
columnHeights[shortestColumn] = y + itemHeight + gap;
|
|
83
|
+
// ============================================
|
|
84
|
+
// Script Loading
|
|
85
|
+
// ============================================
|
|
86
|
+
let twitterScriptLoaded = false;
|
|
87
|
+
let instagramScriptLoaded = false;
|
|
88
|
+
const loadTwitterScript = () => {
|
|
89
|
+
if (twitterScriptLoaded)
|
|
90
|
+
return Promise.resolve();
|
|
91
|
+
if (typeof window === 'undefined')
|
|
92
|
+
return Promise.resolve();
|
|
93
|
+
return new Promise((resolve) => {
|
|
94
|
+
if (window.twttr) {
|
|
95
|
+
twitterScriptLoaded = true;
|
|
96
|
+
resolve();
|
|
97
|
+
return;
|
|
247
98
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
columnWidth,
|
|
99
|
+
const script = document.createElement('script');
|
|
100
|
+
script.src = 'https://platform.twitter.com/widgets.js';
|
|
101
|
+
script.async = true;
|
|
102
|
+
script.onload = () => {
|
|
103
|
+
twitterScriptLoaded = true;
|
|
104
|
+
resolve();
|
|
255
105
|
};
|
|
256
|
-
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
const lines = Math.ceil(textLength / avgCharsPerLine);
|
|
270
|
-
height += lines * 24; // ~24px per line
|
|
271
|
-
}
|
|
272
|
-
else if (post.content.caption) {
|
|
273
|
-
const captionLength = post.content.caption.length;
|
|
274
|
-
const avgCharsPerLine = Math.floor(columnWidth / 8);
|
|
275
|
-
const lines = Math.min(Math.ceil(captionLength / avgCharsPerLine), 4); // Max 4 lines
|
|
276
|
-
height += lines * 20;
|
|
277
|
-
}
|
|
278
|
-
// Media
|
|
279
|
-
if (post.platform === 'twitter' && post.media?.length) {
|
|
280
|
-
const media = post.media[0];
|
|
281
|
-
const aspectRatio = media.aspectRatio ?? 16 / 9;
|
|
282
|
-
height += columnWidth / aspectRatio;
|
|
283
|
-
}
|
|
284
|
-
else if (post.platform === 'instagram') {
|
|
285
|
-
const aspectRatio = post.media.aspectRatio ?? 1;
|
|
286
|
-
height += columnWidth / aspectRatio;
|
|
106
|
+
document.head.appendChild(script);
|
|
107
|
+
});
|
|
108
|
+
};
|
|
109
|
+
const loadInstagramScript = () => {
|
|
110
|
+
if (instagramScriptLoaded)
|
|
111
|
+
return Promise.resolve();
|
|
112
|
+
if (typeof window === 'undefined')
|
|
113
|
+
return Promise.resolve();
|
|
114
|
+
return new Promise((resolve) => {
|
|
115
|
+
if (window.instgrm) {
|
|
116
|
+
instagramScriptLoaded = true;
|
|
117
|
+
resolve();
|
|
118
|
+
return;
|
|
287
119
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* Update single item height and recalculate affected items
|
|
296
|
-
*/
|
|
297
|
-
updateItemHeight(id, height) {
|
|
298
|
-
this.options.itemHeights.set(id, height);
|
|
299
|
-
}
|
|
300
|
-
/**
|
|
301
|
-
* Get current layout state
|
|
302
|
-
*/
|
|
303
|
-
getState() {
|
|
304
|
-
return this.state;
|
|
305
|
-
}
|
|
306
|
-
/**
|
|
307
|
-
* Get position for specific item
|
|
308
|
-
*/
|
|
309
|
-
getPosition(id) {
|
|
310
|
-
return this.state.positions.get(id);
|
|
311
|
-
}
|
|
312
|
-
/**
|
|
313
|
-
* Update container width
|
|
314
|
-
*/
|
|
315
|
-
setContainerWidth(width) {
|
|
316
|
-
this.options.containerWidth = width;
|
|
317
|
-
}
|
|
318
|
-
/**
|
|
319
|
-
* Get current column count
|
|
320
|
-
*/
|
|
321
|
-
getColumnCount() {
|
|
322
|
-
return getColumnCount(this.options.columns, this.options.containerWidth);
|
|
323
|
-
}
|
|
324
|
-
/**
|
|
325
|
-
* Get CSS variables for animations
|
|
326
|
-
*/
|
|
327
|
-
getCSSVariables() {
|
|
328
|
-
return {
|
|
329
|
-
'--sm-animation-duration': `${this.options.animationDuration}ms`,
|
|
330
|
-
'--sm-easing': this.options.easing,
|
|
331
|
-
'--sm-gap': typeof this.options.gap === 'number'
|
|
332
|
-
? `${this.options.gap}px`
|
|
333
|
-
: this.options.gap,
|
|
334
|
-
'--sm-column-width': `${this.state.columnWidth}px`,
|
|
335
|
-
'--sm-container-height': `${this.state.containerHeight}px`,
|
|
120
|
+
const script = document.createElement('script');
|
|
121
|
+
script.src = 'https://www.instagram.com/embed.js';
|
|
122
|
+
script.async = true;
|
|
123
|
+
script.onload = () => {
|
|
124
|
+
instagramScriptLoaded = true;
|
|
125
|
+
resolve();
|
|
336
126
|
};
|
|
337
|
-
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
this.isScrolling = false;
|
|
350
|
-
this.scrollEndTimeout = null;
|
|
351
|
-
this.options = {
|
|
352
|
-
enabled: true,
|
|
353
|
-
overscan: 3,
|
|
354
|
-
estimatedItemHeight: 400,
|
|
355
|
-
scrollContainer: null,
|
|
356
|
-
...options,
|
|
127
|
+
document.head.appendChild(script);
|
|
128
|
+
});
|
|
129
|
+
};
|
|
130
|
+
const TwitterEmbed = ({ url, theme = 'light', onLoad, onError, }) => {
|
|
131
|
+
const containerRef = useRef(null);
|
|
132
|
+
const embedRef = useRef(null);
|
|
133
|
+
const [loading, setLoading] = useState(true);
|
|
134
|
+
const mountedRef = useRef(true);
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
mountedRef.current = true;
|
|
137
|
+
return () => {
|
|
138
|
+
mountedRef.current = false;
|
|
357
139
|
};
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
}
|
|
363
|
-
/**
|
|
364
|
-
* Initialize scroll listener
|
|
365
|
-
*/
|
|
366
|
-
init() {
|
|
367
|
-
if (!this.options.enabled)
|
|
368
|
-
return;
|
|
369
|
-
const container = this.options.scrollContainer ?? window;
|
|
370
|
-
container.addEventListener('scroll', this.scrollHandler, { passive: true });
|
|
371
|
-
// Initial calculation
|
|
372
|
-
this.calculateVisibleItems();
|
|
373
|
-
}
|
|
374
|
-
/**
|
|
375
|
-
* Destroy and cleanup
|
|
376
|
-
*/
|
|
377
|
-
destroy() {
|
|
378
|
-
const container = this.options.scrollContainer ?? window;
|
|
379
|
-
container.removeEventListener('scroll', this.scrollHandler);
|
|
380
|
-
if (this.rafId !== null) {
|
|
381
|
-
cancelRaf(this.rafId);
|
|
382
|
-
}
|
|
383
|
-
if (this.scrollEndTimeout) {
|
|
384
|
-
clearTimeout(this.scrollEndTimeout);
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
/**
|
|
388
|
-
* Handle scroll event
|
|
389
|
-
*/
|
|
390
|
-
handleScroll() {
|
|
391
|
-
if (this.rafId !== null)
|
|
140
|
+
}, []);
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
const embedContainer = embedRef.current;
|
|
143
|
+
if (!embedContainer)
|
|
392
144
|
return;
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
});
|
|
406
|
-
}
|
|
407
|
-
/**
|
|
408
|
-
* Calculate which items are visible
|
|
409
|
-
*/
|
|
410
|
-
calculateVisibleItems() {
|
|
411
|
-
const { scrollTop, viewportHeight } = getScrollPosition(this.options.scrollContainer);
|
|
412
|
-
const overscanPx = this.options.overscan * this.options.estimatedItemHeight;
|
|
413
|
-
this.lastScrollTop = scrollTop;
|
|
414
|
-
const newVisibleItems = [];
|
|
415
|
-
for (let i = 0; i < this.posts.length; i++) {
|
|
416
|
-
const post = this.posts[i];
|
|
417
|
-
const position = this.positions.get(post.id);
|
|
418
|
-
if (!position)
|
|
419
|
-
continue;
|
|
420
|
-
const isVisible = isInViewport({
|
|
421
|
-
top: position.y,
|
|
422
|
-
bottom: position.y + position.height,
|
|
423
|
-
}, scrollTop, viewportHeight, overscanPx);
|
|
424
|
-
if (isVisible) {
|
|
425
|
-
newVisibleItems.push({
|
|
426
|
-
index: i,
|
|
427
|
-
post,
|
|
428
|
-
position,
|
|
429
|
-
isVisible: true,
|
|
430
|
-
});
|
|
145
|
+
// Create a fresh container for the widget
|
|
146
|
+
const widgetContainer = document.createElement('div');
|
|
147
|
+
embedContainer.appendChild(widgetContainer);
|
|
148
|
+
setLoading(true);
|
|
149
|
+
loadTwitterScript().then(() => {
|
|
150
|
+
if (!mountedRef.current)
|
|
151
|
+
return;
|
|
152
|
+
const twttr = window.twttr;
|
|
153
|
+
if (!twttr) {
|
|
154
|
+
onError?.(new Error('Twitter widgets not loaded'));
|
|
155
|
+
setLoading(false);
|
|
156
|
+
return;
|
|
431
157
|
}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
}
|
|
439
|
-
return this.visibleItems;
|
|
440
|
-
}
|
|
441
|
-
/**
|
|
442
|
-
* Check if visible items have changed
|
|
443
|
-
*/
|
|
444
|
-
hasVisibleItemsChanged(newItems) {
|
|
445
|
-
if (newItems.length !== this.visibleItems.length) {
|
|
446
|
-
return true;
|
|
447
|
-
}
|
|
448
|
-
for (let i = 0; i < newItems.length; i++) {
|
|
449
|
-
if (newItems[i].post.id !== this.visibleItems[i].post.id) {
|
|
450
|
-
return true;
|
|
158
|
+
// Extract tweet ID from URL
|
|
159
|
+
const match = url.match(/(?:twitter\.com|x\.com)\/\w+\/status\/(\d+)/);
|
|
160
|
+
if (!match) {
|
|
161
|
+
onError?.(new Error('Invalid Twitter URL'));
|
|
162
|
+
setLoading(false);
|
|
163
|
+
return;
|
|
451
164
|
}
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
*/
|
|
472
|
-
getAllItems() {
|
|
473
|
-
return this.posts.map((post, index) => ({
|
|
474
|
-
index,
|
|
475
|
-
post,
|
|
476
|
-
position: this.positions.get(post.id),
|
|
477
|
-
isVisible: true,
|
|
478
|
-
}));
|
|
479
|
-
}
|
|
480
|
-
/**
|
|
481
|
-
* Check if scrolling
|
|
482
|
-
*/
|
|
483
|
-
getIsScrolling() {
|
|
484
|
-
return this.isScrolling;
|
|
485
|
-
}
|
|
486
|
-
/**
|
|
487
|
-
* Get scroll direction
|
|
488
|
-
*/
|
|
489
|
-
getScrollDirection() {
|
|
490
|
-
const { scrollTop } = getScrollPosition(this.options.scrollContainer);
|
|
491
|
-
if (scrollTop > this.lastScrollTop) {
|
|
492
|
-
return 'down';
|
|
493
|
-
}
|
|
494
|
-
else if (scrollTop < this.lastScrollTop) {
|
|
495
|
-
return 'up';
|
|
496
|
-
}
|
|
497
|
-
return 'none';
|
|
498
|
-
}
|
|
499
|
-
/**
|
|
500
|
-
* Check if near bottom (for infinite scroll)
|
|
501
|
-
*/
|
|
502
|
-
isNearBottom(threshold = 500) {
|
|
503
|
-
const { scrollTop, viewportHeight } = getScrollPosition(this.options.scrollContainer);
|
|
504
|
-
// Get container height from positions
|
|
505
|
-
let maxBottom = 0;
|
|
506
|
-
for (const position of this.positions.values()) {
|
|
507
|
-
maxBottom = Math.max(maxBottom, position.y + position.height);
|
|
508
|
-
}
|
|
509
|
-
return scrollTop + viewportHeight >= maxBottom - threshold;
|
|
510
|
-
}
|
|
511
|
-
/**
|
|
512
|
-
* Scroll to item
|
|
513
|
-
*/
|
|
514
|
-
scrollToItem(id, behavior = 'smooth') {
|
|
515
|
-
const position = this.positions.get(id);
|
|
516
|
-
if (!position)
|
|
517
|
-
return;
|
|
518
|
-
const container = this.options.scrollContainer ?? window;
|
|
519
|
-
if (container === window) {
|
|
520
|
-
window.scrollTo({
|
|
521
|
-
top: position.y,
|
|
522
|
-
behavior,
|
|
523
|
-
});
|
|
524
|
-
}
|
|
525
|
-
else {
|
|
526
|
-
container.scrollTo({
|
|
527
|
-
top: position.y,
|
|
528
|
-
behavior,
|
|
165
|
+
twttr.widgets.createTweet(match[1], widgetContainer, {
|
|
166
|
+
theme,
|
|
167
|
+
conversation: 'none',
|
|
168
|
+
dnt: true,
|
|
169
|
+
}).then((el) => {
|
|
170
|
+
if (!mountedRef.current)
|
|
171
|
+
return;
|
|
172
|
+
setLoading(false);
|
|
173
|
+
if (el) {
|
|
174
|
+
onLoad?.();
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
onError?.(new Error('Tweet not found or unavailable'));
|
|
178
|
+
}
|
|
179
|
+
}).catch((err) => {
|
|
180
|
+
if (!mountedRef.current)
|
|
181
|
+
return;
|
|
182
|
+
setLoading(false);
|
|
183
|
+
onError?.(err);
|
|
529
184
|
});
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
if (this.visibleItems.length === 0) {
|
|
537
|
-
return { start: 0, end: 0 };
|
|
538
|
-
}
|
|
539
|
-
const indices = this.visibleItems.map(item => item.index);
|
|
540
|
-
return {
|
|
541
|
-
start: Math.min(...indices),
|
|
542
|
-
end: Math.max(...indices) + 1,
|
|
185
|
+
});
|
|
186
|
+
return () => {
|
|
187
|
+
// Safely remove the widget container
|
|
188
|
+
if (embedContainer && widgetContainer.parentNode === embedContainer) {
|
|
189
|
+
embedContainer.removeChild(widgetContainer);
|
|
190
|
+
}
|
|
543
191
|
};
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
const
|
|
555
|
-
const
|
|
192
|
+
}, [url, theme, onLoad, onError]);
|
|
193
|
+
return (jsxs("div", { ref: containerRef, className: "sm-twitter-embed", children: [loading && (jsx("div", { style: {
|
|
194
|
+
display: 'flex',
|
|
195
|
+
alignItems: 'center',
|
|
196
|
+
justifyContent: 'center',
|
|
197
|
+
minHeight: 200,
|
|
198
|
+
backgroundColor: theme === 'dark' ? '#15202b' : '#f5f5f5',
|
|
199
|
+
borderRadius: 12,
|
|
200
|
+
}, children: jsx("div", { style: { color: theme === 'dark' ? '#8899a6' : '#666' }, children: "Loading..." }) })), jsx("div", { ref: embedRef })] }));
|
|
201
|
+
};
|
|
202
|
+
const InstagramEmbed = ({ url, onLoad, onError, }) => {
|
|
203
|
+
const containerRef = useRef(null);
|
|
204
|
+
const embedRef = useRef(null);
|
|
205
|
+
const [loading, setLoading] = useState(true);
|
|
206
|
+
const mountedRef = useRef(true);
|
|
556
207
|
useEffect(() => {
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
208
|
+
mountedRef.current = true;
|
|
209
|
+
return () => {
|
|
210
|
+
mountedRef.current = false;
|
|
211
|
+
};
|
|
212
|
+
}, []);
|
|
213
|
+
useEffect(() => {
|
|
214
|
+
const embedContainer = embedRef.current;
|
|
215
|
+
if (!embedContainer)
|
|
216
|
+
return;
|
|
217
|
+
// Extract post ID from URL
|
|
218
|
+
const match = url.match(/instagram\.com\/(?:p|reel)\/([A-Za-z0-9_-]+)/);
|
|
219
|
+
if (!match) {
|
|
220
|
+
onError?.(new Error('Invalid Instagram URL'));
|
|
221
|
+
setLoading(false);
|
|
567
222
|
return;
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
223
|
+
}
|
|
224
|
+
// Create a fresh container for the widget
|
|
225
|
+
const widgetContainer = document.createElement('div');
|
|
226
|
+
widgetContainer.innerHTML = `
|
|
227
|
+
<blockquote
|
|
228
|
+
class="instagram-media"
|
|
229
|
+
data-instgrm-captioned
|
|
230
|
+
data-instgrm-permalink="https://www.instagram.com/p/${match[1]}/"
|
|
231
|
+
data-instgrm-version="14"
|
|
232
|
+
style="
|
|
233
|
+
background:#FFF;
|
|
234
|
+
border:0;
|
|
235
|
+
border-radius:12px;
|
|
236
|
+
box-shadow:0 0 1px 0 rgba(0,0,0,0.5),0 1px 10px 0 rgba(0,0,0,0.15);
|
|
237
|
+
margin: 0;
|
|
238
|
+
max-width:100%;
|
|
239
|
+
min-width:100%;
|
|
240
|
+
padding:0;
|
|
241
|
+
width:100%;
|
|
242
|
+
"
|
|
243
|
+
>
|
|
244
|
+
</blockquote>
|
|
245
|
+
`;
|
|
246
|
+
embedContainer.appendChild(widgetContainer);
|
|
247
|
+
loadInstagramScript().then(() => {
|
|
248
|
+
if (!mountedRef.current)
|
|
249
|
+
return;
|
|
250
|
+
const instgrm = window.instgrm;
|
|
251
|
+
if (instgrm) {
|
|
252
|
+
instgrm.Embeds.process();
|
|
253
|
+
setLoading(false);
|
|
254
|
+
onLoad?.();
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
return () => {
|
|
258
|
+
// Safely remove the widget container
|
|
259
|
+
if (embedContainer && widgetContainer.parentNode === embedContainer) {
|
|
260
|
+
embedContainer.removeChild(widgetContainer);
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
}, [url, onLoad, onError]);
|
|
264
|
+
return (jsxs("div", { ref: containerRef, className: "sm-instagram-embed", children: [loading && (jsx("div", { style: {
|
|
265
|
+
display: 'flex',
|
|
266
|
+
alignItems: 'center',
|
|
267
|
+
justifyContent: 'center',
|
|
268
|
+
minHeight: 400,
|
|
269
|
+
backgroundColor: '#fafafa',
|
|
270
|
+
borderRadius: 12,
|
|
271
|
+
}, children: jsx("div", { style: { color: '#666' }, children: "Loading..." }) })), jsx("div", { ref: embedRef })] }));
|
|
610
272
|
};
|
|
611
273
|
const SocialMasonry = forwardRef((props, ref) => {
|
|
612
|
-
const { posts: initialPosts = [], gap = 16, columns = defaultColumnConfig,
|
|
274
|
+
const { posts: initialPosts = [], gap = 16, columns = defaultColumnConfig, theme = 'light', onEmbedLoad, onEmbedError, className, style, } = props;
|
|
613
275
|
const containerRef = useRef(null);
|
|
614
276
|
const [posts, setPosts] = useState(initialPosts);
|
|
615
|
-
const [
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
gap,
|
|
622
|
-
columns,
|
|
623
|
-
padding,
|
|
624
|
-
animationDuration,
|
|
625
|
-
animate,
|
|
626
|
-
easing,
|
|
627
|
-
containerWidth: containerRef.current?.clientWidth || 800,
|
|
628
|
-
itemHeights,
|
|
629
|
-
});
|
|
630
|
-
}, [gap, columns, padding, animationDuration, animate, easing]);
|
|
631
|
-
const virtualizationEngine = useMemo(() => {
|
|
632
|
-
if (!virtualization?.enabled)
|
|
633
|
-
return null;
|
|
634
|
-
return new VirtualizationEngine({
|
|
635
|
-
...virtualization,
|
|
636
|
-
posts,
|
|
637
|
-
positions,
|
|
638
|
-
onVisibleItemsChange: setVisibleItems,
|
|
639
|
-
});
|
|
640
|
-
}, [virtualization?.enabled]);
|
|
641
|
-
// Calculate layout
|
|
642
|
-
const calculateLayout = useCallback(() => {
|
|
643
|
-
if (!containerRef.current)
|
|
644
|
-
return;
|
|
645
|
-
layoutEngine.setContainerWidth(containerRef.current.clientWidth);
|
|
646
|
-
const state = layoutEngine.calculate(posts);
|
|
647
|
-
setPositions(state.positions);
|
|
648
|
-
setContainerHeight(state.containerHeight);
|
|
649
|
-
if (virtualizationEngine) {
|
|
650
|
-
virtualizationEngine.update(posts, state.positions);
|
|
651
|
-
setVisibleItems(virtualizationEngine.calculateVisibleItems());
|
|
652
|
-
}
|
|
653
|
-
onLayoutComplete?.(Array.from(state.positions.values()));
|
|
654
|
-
}, [posts, layoutEngine, virtualizationEngine, onLayoutComplete]);
|
|
655
|
-
// Handle resize
|
|
277
|
+
const [columnCount, setColumnCount] = useState(3);
|
|
278
|
+
// Update posts when initialPosts changes
|
|
279
|
+
useEffect(() => {
|
|
280
|
+
setPosts(initialPosts);
|
|
281
|
+
}, [initialPosts]);
|
|
282
|
+
// Calculate column count based on container width
|
|
656
283
|
useEffect(() => {
|
|
657
|
-
const
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
284
|
+
const updateColumnCount = () => {
|
|
285
|
+
if (!containerRef.current)
|
|
286
|
+
return;
|
|
287
|
+
const width = containerRef.current.clientWidth;
|
|
288
|
+
const count = getColumnCount(columns, width);
|
|
289
|
+
setColumnCount(count);
|
|
290
|
+
};
|
|
291
|
+
updateColumnCount();
|
|
292
|
+
const resizeObserver = new ResizeObserver(updateColumnCount);
|
|
661
293
|
if (containerRef.current) {
|
|
662
294
|
resizeObserver.observe(containerRef.current);
|
|
663
295
|
}
|
|
664
|
-
return () =>
|
|
665
|
-
|
|
666
|
-
};
|
|
667
|
-
}, [calculateLayout]);
|
|
668
|
-
// Initial layout
|
|
669
|
-
useEffect(() => {
|
|
670
|
-
calculateLayout();
|
|
671
|
-
}, [posts, calculateLayout]);
|
|
672
|
-
// Initialize virtualization
|
|
673
|
-
useEffect(() => {
|
|
674
|
-
if (virtualizationEngine) {
|
|
675
|
-
virtualizationEngine.init();
|
|
676
|
-
return () => virtualizationEngine.destroy();
|
|
677
|
-
}
|
|
678
|
-
return undefined;
|
|
679
|
-
}, [virtualizationEngine]);
|
|
680
|
-
// Handle height change
|
|
681
|
-
const handleHeightChange = useCallback((id, height) => {
|
|
682
|
-
itemHeights.set(id, height);
|
|
683
|
-
calculateLayout();
|
|
684
|
-
}, [itemHeights, calculateLayout]);
|
|
296
|
+
return () => resizeObserver.disconnect();
|
|
297
|
+
}, [columns]);
|
|
685
298
|
// Expose methods via ref
|
|
686
299
|
useImperativeHandle(ref, () => ({
|
|
687
300
|
addPosts: (newPosts) => {
|
|
688
301
|
setPosts(prev => [...prev, ...newPosts]);
|
|
689
302
|
},
|
|
690
303
|
setPosts: (newPosts) => {
|
|
691
|
-
itemHeights.clear();
|
|
692
304
|
setPosts(newPosts);
|
|
693
305
|
},
|
|
694
306
|
removePost: (id) => {
|
|
695
|
-
|
|
696
|
-
setPosts(prev => prev.filter(p => p.id !== id));
|
|
307
|
+
setPosts(prev => prev.filter(p => generatePostId(p) !== id));
|
|
697
308
|
},
|
|
698
309
|
refresh: () => {
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
const position = positions.get(id);
|
|
704
|
-
if (position) {
|
|
705
|
-
window.scrollTo({ top: position.y, behavior });
|
|
310
|
+
// Re-process embeds
|
|
311
|
+
const instgrm = window.instgrm;
|
|
312
|
+
if (instgrm) {
|
|
313
|
+
instgrm.Embeds.process();
|
|
706
314
|
}
|
|
707
315
|
},
|
|
708
|
-
}), [
|
|
709
|
-
//
|
|
710
|
-
const
|
|
711
|
-
|
|
712
|
-
|
|
316
|
+
}), []);
|
|
317
|
+
// Distribute posts into columns (CSS-based masonry simulation)
|
|
318
|
+
const distributeToColumns = useCallback(() => {
|
|
319
|
+
const cols = Array.from({ length: columnCount }, () => []);
|
|
320
|
+
posts.forEach((post, index) => {
|
|
321
|
+
cols[index % columnCount].push(post);
|
|
322
|
+
});
|
|
323
|
+
return cols;
|
|
324
|
+
}, [posts, columnCount]);
|
|
325
|
+
const postColumns = distributeToColumns();
|
|
713
326
|
return (jsxs("div", { ref: containerRef, className: `sm-container ${className || ''}`, style: {
|
|
714
|
-
|
|
327
|
+
display: 'flex',
|
|
328
|
+
gap,
|
|
715
329
|
width: '100%',
|
|
716
|
-
height: containerHeight,
|
|
717
330
|
...style,
|
|
718
|
-
}, children: [
|
|
719
|
-
|
|
720
|
-
|
|
331
|
+
}, children: [postColumns.map((columnPosts, colIndex) => (jsx("div", { className: "sm-column", style: {
|
|
332
|
+
flex: 1,
|
|
333
|
+
display: 'flex',
|
|
334
|
+
flexDirection: 'column',
|
|
335
|
+
gap,
|
|
336
|
+
minWidth: 0,
|
|
337
|
+
}, children: columnPosts.map(post => {
|
|
338
|
+
const postId = generatePostId(post);
|
|
339
|
+
if (post.platform === 'twitter') {
|
|
340
|
+
return (jsx("div", { className: "sm-embed sm-embed--twitter", children: jsx(TwitterEmbed, { url: post.url, theme: theme, onLoad: () => onEmbedLoad?.(post), onError: (error) => onEmbedError?.(post, error) }) }, postId));
|
|
341
|
+
}
|
|
342
|
+
if (post.platform === 'instagram') {
|
|
343
|
+
return (jsx("div", { className: "sm-embed sm-embed--instagram", children: jsx(InstagramEmbed, { url: post.url, onLoad: () => onEmbedLoad?.(post), onError: (error) => onEmbedError?.(post, error) }) }, postId));
|
|
344
|
+
}
|
|
721
345
|
return null;
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
346
|
+
}) }, colIndex))), posts.length === 0 && (jsx("div", { className: "sm-empty", style: {
|
|
347
|
+
display: 'flex',
|
|
348
|
+
alignItems: 'center',
|
|
349
|
+
justifyContent: 'center',
|
|
350
|
+
padding: 40,
|
|
351
|
+
color: '#666',
|
|
352
|
+
fontSize: 14,
|
|
353
|
+
width: '100%',
|
|
354
|
+
}, children: "No posts to display" }))] }));
|
|
726
355
|
});
|
|
727
356
|
SocialMasonry.displayName = 'SocialMasonry';
|
|
728
357
|
|