@gallop.software/studio 1.5.9 → 2.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.
Files changed (60) hide show
  1. package/app/api/studio/[...path]/route.ts +1 -0
  2. package/app/layout.tsx +20 -0
  3. package/app/page.tsx +82 -0
  4. package/bin/studio.mjs +110 -0
  5. package/dist/handlers/index.js +84 -63
  6. package/dist/handlers/index.js.map +1 -1
  7. package/dist/handlers/index.mjs +135 -114
  8. package/dist/handlers/index.mjs.map +1 -1
  9. package/dist/index.d.mts +14 -10
  10. package/dist/index.d.ts +14 -10
  11. package/dist/index.js +2 -177
  12. package/dist/index.js.map +1 -1
  13. package/dist/index.mjs +4 -179
  14. package/dist/index.mjs.map +1 -1
  15. package/next.config.mjs +22 -0
  16. package/package.json +18 -10
  17. package/src/components/AddNewModal.tsx +402 -0
  18. package/src/components/ErrorModal.tsx +89 -0
  19. package/src/components/R2SetupModal.tsx +400 -0
  20. package/src/components/StudioBreadcrumb.tsx +115 -0
  21. package/src/components/StudioButton.tsx +200 -0
  22. package/src/components/StudioContext.tsx +219 -0
  23. package/src/components/StudioDetailView.tsx +714 -0
  24. package/src/components/StudioFileGrid.tsx +704 -0
  25. package/src/components/StudioFileList.tsx +743 -0
  26. package/src/components/StudioFolderPicker.tsx +342 -0
  27. package/src/components/StudioModal.tsx +473 -0
  28. package/src/components/StudioPreview.tsx +399 -0
  29. package/src/components/StudioSettings.tsx +536 -0
  30. package/src/components/StudioToolbar.tsx +1448 -0
  31. package/src/components/StudioUI.tsx +731 -0
  32. package/src/components/styles/common.ts +236 -0
  33. package/src/components/tokens.ts +78 -0
  34. package/src/components/useStudioActions.tsx +497 -0
  35. package/src/config/index.ts +7 -0
  36. package/src/config/workspace.ts +52 -0
  37. package/src/handlers/favicon.ts +152 -0
  38. package/src/handlers/files.ts +784 -0
  39. package/src/handlers/images.ts +949 -0
  40. package/src/handlers/import.ts +190 -0
  41. package/src/handlers/index.ts +168 -0
  42. package/src/handlers/list.ts +627 -0
  43. package/src/handlers/scan.ts +311 -0
  44. package/src/handlers/utils/cdn.ts +234 -0
  45. package/src/handlers/utils/files.ts +64 -0
  46. package/src/handlers/utils/index.ts +4 -0
  47. package/src/handlers/utils/meta.ts +102 -0
  48. package/src/handlers/utils/thumbnails.ts +98 -0
  49. package/src/hooks/useFileList.ts +143 -0
  50. package/src/index.tsx +36 -0
  51. package/src/lib/api.ts +176 -0
  52. package/src/types.ts +119 -0
  53. package/dist/StudioUI-GJK45R3T.js +0 -6500
  54. package/dist/StudioUI-GJK45R3T.js.map +0 -1
  55. package/dist/StudioUI-QZ54STXE.mjs +0 -6500
  56. package/dist/StudioUI-QZ54STXE.mjs.map +0 -1
  57. package/dist/chunk-N6JYTJCB.js +0 -68
  58. package/dist/chunk-N6JYTJCB.js.map +0 -1
  59. package/dist/chunk-RHI3UROE.mjs +0 -68
  60. package/dist/chunk-RHI3UROE.mjs.map +0 -1
@@ -0,0 +1,743 @@
1
+ /** @jsxImportSource @emotion/react */
2
+ 'use client'
3
+
4
+ import { useState } from 'react'
5
+ import { css, keyframes } from '@emotion/react'
6
+ import { useFileList } from '../hooks/useFileList'
7
+ import { colors, fontSize } from './tokens'
8
+ import type { FileItem } from '../types'
9
+
10
+ const spin = keyframes`
11
+ to { transform: rotate(360deg); }
12
+ `
13
+
14
+ const styles = {
15
+ loading: css`
16
+ display: flex;
17
+ align-items: center;
18
+ justify-content: center;
19
+ flex: 1;
20
+ min-height: 300px;
21
+ `,
22
+ spinner: css`
23
+ width: 32px;
24
+ height: 32px;
25
+ border-radius: 50%;
26
+ border: 3px solid ${colors.border};
27
+ border-top-color: ${colors.primary};
28
+ animation: ${spin} 0.8s linear infinite;
29
+ `,
30
+ empty: css`
31
+ display: flex;
32
+ flex-direction: column;
33
+ align-items: center;
34
+ justify-content: center;
35
+ flex: 1;
36
+ min-height: 300px;
37
+ color: ${colors.textSecondary};
38
+ `,
39
+ emptyHint: css`
40
+ font-size: ${fontSize.sm};
41
+ color: ${colors.textMuted};
42
+ margin-top: 4px;
43
+ `,
44
+ scanButton: css`
45
+ margin-top: 16px;
46
+ padding: 10px 24px;
47
+ font-size: ${fontSize.base};
48
+ font-weight: 500;
49
+ background: ${colors.primary};
50
+ color: white;
51
+ border: none;
52
+ border-radius: 8px;
53
+ cursor: pointer;
54
+ transition: background 0.15s ease;
55
+
56
+ &:hover:not(:disabled) {
57
+ background: ${colors.primaryHover};
58
+ }
59
+
60
+ &:disabled {
61
+ opacity: 0.6;
62
+ cursor: not-allowed;
63
+ }
64
+ `,
65
+ tableWrapper: css`
66
+ background: ${colors.surface};
67
+ border-radius: 8px;
68
+ border: 1px solid ${colors.border};
69
+ overflow-x: auto;
70
+ `,
71
+ table: css`
72
+ width: 100%;
73
+ min-width: 600px;
74
+ border-collapse: collapse;
75
+ white-space: nowrap;
76
+ `,
77
+ th: css`
78
+ text-align: left;
79
+ font-size: 11px;
80
+ color: ${colors.textMuted};
81
+ text-transform: uppercase;
82
+ letter-spacing: 0.05em;
83
+ padding: 12px 16px;
84
+ font-weight: 600;
85
+ background: ${colors.background};
86
+ border-bottom: 1px solid ${colors.border};
87
+ `,
88
+ thCheckbox: css`
89
+ width: 48px;
90
+ `,
91
+ thSize: css`
92
+ width: 96px;
93
+ `,
94
+ thDimensions: css`
95
+ width: 128px;
96
+ `,
97
+ thCdn: css`
98
+ width: 96px;
99
+ `,
100
+ tbody: css``,
101
+ row: css`
102
+ cursor: pointer;
103
+ transition: background-color 0.15s ease;
104
+ user-select: none;
105
+
106
+ &:hover {
107
+ background-color: ${colors.surfaceHover};
108
+ }
109
+
110
+ &:not(:last-child) td {
111
+ border-bottom: 1px solid ${colors.borderLight};
112
+ }
113
+ `,
114
+ rowSelected: css`
115
+ background-color: ${colors.primaryLight};
116
+
117
+ &:hover {
118
+ background-color: ${colors.primaryLight};
119
+ }
120
+ `,
121
+ parentRow: css`
122
+ cursor: pointer;
123
+ border-bottom: 1px solid ${colors.border};
124
+
125
+ &:hover {
126
+ background-color: ${colors.surfaceHover};
127
+ }
128
+ `,
129
+ td: css`
130
+ padding: 12px 16px;
131
+ `,
132
+ checkboxCell: css`
133
+ padding: 12px 16px;
134
+ cursor: pointer;
135
+ vertical-align: middle;
136
+ `,
137
+ checkbox: css`
138
+ width: 18px;
139
+ height: 18px;
140
+ accent-color: ${colors.primary};
141
+ cursor: pointer;
142
+ display: block;
143
+ `,
144
+ actionsCell: css`
145
+ display: flex;
146
+ align-items: center;
147
+ justify-content: flex-end;
148
+ gap: 8px;
149
+ margin-left: auto;
150
+ flex-shrink: 0;
151
+ `,
152
+ copyBtn: css`
153
+ position: relative;
154
+ flex-shrink: 0;
155
+ height: 32px;
156
+ width: 32px;
157
+ font-size: ${fontSize.xs};
158
+ color: ${colors.textSecondary};
159
+ background: ${colors.surface};
160
+ border: 1px solid ${colors.border};
161
+ padding: 0;
162
+ cursor: pointer;
163
+ border-radius: 4px;
164
+ transition: all 0.15s ease;
165
+ display: inline-flex;
166
+ align-items: center;
167
+ justify-content: center;
168
+
169
+ &:hover {
170
+ background: ${colors.surfaceHover};
171
+ border-color: ${colors.borderHover};
172
+ color: ${colors.text};
173
+ }
174
+ `,
175
+ copyIcon: css`
176
+ width: 16px;
177
+ height: 16px;
178
+ `,
179
+ statusBtn: css`
180
+ flex-shrink: 0;
181
+ height: 32px;
182
+ width: 32px;
183
+ background: ${colors.surface};
184
+ border: 1px solid ${colors.border};
185
+ padding: 0;
186
+ border-radius: 4px;
187
+ display: inline-flex;
188
+ align-items: center;
189
+ justify-content: center;
190
+ `,
191
+ cloudIcon: css`
192
+ width: 18px;
193
+ height: 18px;
194
+ color: #f59e0b;
195
+ transform: translateY(1px);
196
+ `,
197
+ folderStats: css`
198
+ display: flex;
199
+ align-items: center;
200
+ gap: 8px;
201
+ `,
202
+ folderStat: css`
203
+ display: flex;
204
+ align-items: center;
205
+ gap: 3px;
206
+ font-size: ${fontSize.xs};
207
+ `,
208
+ folderStatLocal: css`
209
+ color: ${colors.textMuted};
210
+ `,
211
+ folderStatCloud: css`
212
+ color: #f59e0b;
213
+ `,
214
+ folderStatRemote: css`
215
+ color: #ef4444;
216
+ `,
217
+ folderStatIconCloud: css`
218
+ width: 14px;
219
+ height: 14px;
220
+ color: #f59e0b;
221
+ `,
222
+ folderStatIconLocal: css`
223
+ width: 14px;
224
+ height: 14px;
225
+ color: ${colors.textSecondary};
226
+ `,
227
+ folderStatIconRemote: css`
228
+ width: 14px;
229
+ height: 14px;
230
+ color: #ef4444;
231
+ `,
232
+ storedLabel: css`
233
+ display: flex;
234
+ align-items: center;
235
+ `,
236
+ storedIconCloud: css`
237
+ width: 16px;
238
+ height: 16px;
239
+ color: #f59e0b;
240
+ `,
241
+ storedIconRemote: css`
242
+ width: 16px;
243
+ height: 16px;
244
+ color: #ef4444;
245
+ `,
246
+ globeIcon: css`
247
+ width: 16px;
248
+ height: 16px;
249
+ color: #ef4444;
250
+ `,
251
+ tooltip: css`
252
+ position: absolute;
253
+ top: 50%;
254
+ right: 100%;
255
+ transform: translateY(-50%);
256
+ background: #1a1f36;
257
+ color: white;
258
+ padding: 4px 8px;
259
+ border-radius: 4px;
260
+ font-size: 12px;
261
+ white-space: nowrap;
262
+ margin-right: 6px;
263
+ pointer-events: none;
264
+ z-index: 100;
265
+
266
+ &::after {
267
+ content: '';
268
+ position: absolute;
269
+ left: 100%;
270
+ top: 50%;
271
+ transform: translateY(-50%);
272
+ border: 4px solid transparent;
273
+ border-left-color: #1a1f36;
274
+ }
275
+ `,
276
+ nameCell: css`
277
+ display: flex;
278
+ align-items: center;
279
+ gap: 12px;
280
+ flex: 1;
281
+ `,
282
+ thumbnailWrapper: css`
283
+ width: 48px;
284
+ height: 36px;
285
+ display: flex;
286
+ align-items: center;
287
+ justify-content: center;
288
+ flex-shrink: 0;
289
+ `,
290
+ folderIconWrapper: css`
291
+ width: 48px;
292
+ height: 36px;
293
+ display: flex;
294
+ align-items: center;
295
+ justify-content: center;
296
+ flex-shrink: 0;
297
+ `,
298
+ folderIcon: css`
299
+ width: 24px;
300
+ height: 24px;
301
+ color: #f9935e;
302
+ `,
303
+ imagesFolderWrapper: css`
304
+ width: 48px;
305
+ height: 36px;
306
+ display: flex;
307
+ align-items: center;
308
+ justify-content: center;
309
+ flex-shrink: 0;
310
+ position: relative;
311
+ align-items: center;
312
+ `,
313
+ imagesFolderIcon: css`
314
+ width: 24px;
315
+ height: 24px;
316
+ color: ${colors.imagesFolder};
317
+ `,
318
+ lockIcon: css`
319
+ width: 10px;
320
+ height: 10px;
321
+ color: ${colors.imagesFolder};
322
+ margin-left: -6px;
323
+ margin-top: 8px;
324
+ `,
325
+ parentIcon: css`
326
+ width: 20px;
327
+ height: 20px;
328
+ color: ${colors.textMuted};
329
+ flex-shrink: 0;
330
+ `,
331
+ fileIcon: css`
332
+ width: 20px;
333
+ height: 20px;
334
+ color: ${colors.textMuted};
335
+ flex-shrink: 0;
336
+ `,
337
+ thumbnail: css`
338
+ max-width: 100%;
339
+ max-height: 100%;
340
+ width: auto;
341
+ height: auto;
342
+ object-fit: contain;
343
+ border-radius: 4px;
344
+ border: 1px solid ${colors.borderLight};
345
+ `,
346
+ noThumbnail: css`
347
+ width: 36px;
348
+ height: 36px;
349
+ display: flex;
350
+ align-items: center;
351
+ justify-content: center;
352
+ background: ${colors.background};
353
+ border: 1px dashed ${colors.border};
354
+ border-radius: 4px;
355
+ flex-shrink: 0;
356
+ cursor: pointer;
357
+ transition: all 0.15s ease;
358
+
359
+ &:hover {
360
+ border-color: ${colors.primary};
361
+ background: ${colors.surfaceHover};
362
+ }
363
+ `,
364
+ noThumbnailIcon: css`
365
+ width: 16px;
366
+ height: 16px;
367
+ color: ${colors.textMuted};
368
+ `,
369
+ name: css`
370
+ font-size: ${fontSize.base};
371
+ font-weight: 500;
372
+ color: ${colors.text};
373
+ letter-spacing: -0.01em;
374
+ overflow: hidden;
375
+ text-overflow: ellipsis;
376
+ white-space: nowrap;
377
+ max-width: 300px;
378
+ `,
379
+ meta: css`
380
+ font-size: ${fontSize.sm};
381
+ color: ${colors.textSecondary};
382
+ `,
383
+ cdnBadge: css`
384
+ display: inline-flex;
385
+ align-items: center;
386
+ gap: 4px;
387
+ font-size: ${fontSize.xs};
388
+ font-weight: 500;
389
+ color: ${colors.success};
390
+ `,
391
+ cdnBadgeRemote: css`
392
+ display: inline-flex;
393
+ align-items: center;
394
+ font-size: ${fontSize.xs};
395
+ font-weight: 500;
396
+ color: #ef4444;
397
+ `,
398
+ cdnIcon: css`
399
+ width: 12px;
400
+ height: 12px;
401
+ `,
402
+ cdnEmpty: css`
403
+ font-size: ${fontSize.sm};
404
+ color: ${colors.textMuted};
405
+ `,
406
+ openBtn: css`
407
+ height: 32px;
408
+ font-size: ${fontSize.sm};
409
+ font-weight: 500;
410
+ color: ${colors.primary};
411
+ background: ${colors.surface};
412
+ border: 1px solid ${colors.border};
413
+ padding: 0 14px;
414
+ cursor: pointer;
415
+ border-radius: 4px;
416
+ transition: all 0.15s ease;
417
+ display: inline-flex;
418
+ align-items: center;
419
+
420
+ &:hover {
421
+ background-color: ${colors.primaryLight};
422
+ border-color: ${colors.primary};
423
+ }
424
+ `,
425
+ }
426
+
427
+ export function StudioFileList() {
428
+ const {
429
+ loading,
430
+ sortedItems,
431
+ metaEmpty,
432
+ isAtRoot,
433
+ isSearching,
434
+ allItemsSelected,
435
+ someItemsSelected,
436
+ selectedItems,
437
+ navigateUp,
438
+ handleItemClick,
439
+ handleOpen,
440
+ handleGenerateThumbnail,
441
+ handleSelectAll,
442
+ triggerScan,
443
+ } = useFileList()
444
+
445
+ if (loading) {
446
+ return (
447
+ <div css={styles.loading}>
448
+ <div css={styles.spinner} />
449
+ </div>
450
+ )
451
+ }
452
+
453
+ if (metaEmpty && isAtRoot) {
454
+ return (
455
+ <div css={styles.empty}>
456
+ <p>No files tracked yet</p>
457
+ <p css={styles.emptyHint}>Click Scan to discover files in your public folder</p>
458
+ <button
459
+ css={styles.scanButton}
460
+ onClick={triggerScan}
461
+ >
462
+ Scan for Files
463
+ </button>
464
+ </div>
465
+ )
466
+ }
467
+
468
+ if (sortedItems.length === 0 && isAtRoot) {
469
+ return (
470
+ <div css={styles.empty}>
471
+ <p>No files in this folder</p>
472
+ <p css={styles.emptyHint}>Upload images or click Scan in the toolbar</p>
473
+ </div>
474
+ )
475
+ }
476
+
477
+ return (
478
+ <div css={styles.tableWrapper}>
479
+ <table css={styles.table}>
480
+ <thead>
481
+ <tr>
482
+ <th css={[styles.th, styles.thCheckbox]}>
483
+ {sortedItems.length > 0 && (
484
+ <input
485
+ type="checkbox"
486
+ css={styles.checkbox}
487
+ checked={allItemsSelected}
488
+ ref={(el) => {
489
+ if (el) el.indeterminate = someItemsSelected && !allItemsSelected
490
+ }}
491
+ onChange={handleSelectAll}
492
+ />
493
+ )}
494
+ </th>
495
+ <th css={styles.th}>Name</th>
496
+ <th css={[styles.th, styles.thSize]}>Size</th>
497
+ <th css={[styles.th, styles.thDimensions]}>Dimensions</th>
498
+ <th css={[styles.th, styles.thCdn]}>CDN</th>
499
+ </tr>
500
+ </thead>
501
+ <tbody css={styles.tbody}>
502
+ {/* Parent folder navigation - hide when searching */}
503
+ {!isAtRoot && !isSearching && (
504
+ <tr css={styles.parentRow} onClick={navigateUp}>
505
+ <td css={styles.td}></td>
506
+ <td css={styles.td}>
507
+ <div css={styles.nameCell}>
508
+ <svg css={styles.parentIcon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
509
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
510
+ </svg>
511
+ <span css={styles.name}>..</span>
512
+ </div>
513
+ </td>
514
+ <td css={[styles.td, styles.meta]}>--</td>
515
+ <td css={[styles.td, styles.meta]}>Parent folder</td>
516
+ <td css={styles.td}>--</td>
517
+ </tr>
518
+ )}
519
+
520
+ {sortedItems.map((item) => (
521
+ <ListRow
522
+ key={item.path}
523
+ item={item}
524
+ isSelected={selectedItems.has(item.path)}
525
+ onClick={(e) => handleItemClick(item, e)}
526
+ onOpen={() => handleOpen(item)}
527
+ onGenerateThumbnail={() => handleGenerateThumbnail(item)}
528
+ />
529
+ ))}
530
+ </tbody>
531
+ </table>
532
+ </div>
533
+ )
534
+ }
535
+
536
+ interface ListRowProps {
537
+ item: FileItem
538
+ isSelected: boolean
539
+ onClick: (e: React.MouseEvent) => void
540
+ onOpen: () => void
541
+ onGenerateThumbnail: () => void
542
+ }
543
+
544
+ function ListRow({ item, isSelected, onClick, onOpen, onGenerateThumbnail }: ListRowProps) {
545
+ const [showCopied, setShowCopied] = useState(false)
546
+ const isFolder = item.type === 'folder'
547
+ const isImage = !isFolder && item.thumbnail !== undefined
548
+ const isProtected = item.isProtected || (isFolder && item.name === 'images' && item.path === 'public/images')
549
+
550
+ const handleCopyPath = (e: React.MouseEvent) => {
551
+ e.stopPropagation()
552
+ const pathToCopy = '/' + item.path.replace(/^public\//, '')
553
+ navigator.clipboard.writeText(pathToCopy)
554
+ setShowCopied(true)
555
+ setTimeout(() => setShowCopied(false), 1500)
556
+ }
557
+
558
+ const handleClick = (e: React.MouseEvent) => {
559
+ // Protected items cannot be selected, only opened
560
+ if (isProtected) {
561
+ e.stopPropagation()
562
+ onOpen()
563
+ return
564
+ }
565
+ onClick(e)
566
+ }
567
+
568
+ return (
569
+ <tr
570
+ css={[styles.row, isSelected && !isProtected && styles.rowSelected]}
571
+ onClick={handleClick}
572
+ >
573
+ <td
574
+ css={[styles.td, styles.checkboxCell]}
575
+ onClick={(e) => e.stopPropagation()}
576
+ >
577
+ {!isProtected && (
578
+ <input
579
+ type="checkbox"
580
+ css={styles.checkbox}
581
+ checked={isSelected}
582
+ onChange={() => onClick({} as React.MouseEvent)}
583
+ />
584
+ )}
585
+ </td>
586
+ <td css={styles.td}>
587
+ <div css={styles.nameCell}>
588
+ {isFolder ? (
589
+ isProtected ? (
590
+ <div css={styles.imagesFolderWrapper}>
591
+ <svg css={styles.imagesFolderIcon} fill="currentColor" viewBox="0 0 24 24">
592
+ <path d="M10 4H4a2 2 0 00-2 2v12a2 2 0 002 2h16a2 2 0 002-2V8a2 2 0 00-2-2h-8l-2-2z" />
593
+ </svg>
594
+ <svg css={styles.lockIcon} fill="currentColor" viewBox="0 0 20 20">
595
+ <path fillRule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clipRule="evenodd" />
596
+ </svg>
597
+ </div>
598
+ ) : (
599
+ <div css={styles.folderIconWrapper}>
600
+ <svg css={styles.folderIcon} fill="currentColor" viewBox="0 0 24 24">
601
+ <path d="M10 4H4a2 2 0 00-2 2v12a2 2 0 002 2h16a2 2 0 002-2V8a2 2 0 00-2-2h-8l-2-2z" />
602
+ </svg>
603
+ </div>
604
+ )
605
+ ) : isImage && item.thumbnail ? (
606
+ <div css={styles.thumbnailWrapper}>
607
+ <img css={styles.thumbnail} src={item.thumbnail} alt={item.name} loading="lazy" />
608
+ </div>
609
+ ) : (
610
+ <div css={styles.thumbnailWrapper}>
611
+ <svg css={styles.fileIcon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
612
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
613
+ </svg>
614
+ </div>
615
+ )}
616
+ <span css={styles.name} title={item.name}>{truncateMiddle(item.name)}</span>
617
+ <div css={styles.actionsCell}>
618
+ <button
619
+ css={styles.copyBtn}
620
+ onClick={handleCopyPath}
621
+ title="Copy file path"
622
+ >
623
+ {showCopied && <span css={styles.tooltip}>Copied!</span>}
624
+ <svg css={styles.copyIcon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
625
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
626
+ </svg>
627
+ </button>
628
+ <button
629
+ css={styles.openBtn}
630
+ onClick={(e) => {
631
+ e.stopPropagation()
632
+ onOpen()
633
+ }}
634
+ >
635
+ Open
636
+ </button>
637
+ </div>
638
+ </div>
639
+ </td>
640
+ <td css={[styles.td, styles.meta]}>
641
+ {isFolder ? (
642
+ <div css={styles.folderStats}>
643
+ {item.localCount !== undefined && item.localCount > 0 && (
644
+ <span css={[styles.folderStat, styles.folderStatLocal]} title={`${item.localCount} local`}>
645
+ <svg css={styles.folderStatIconLocal} fill="none" stroke="currentColor" viewBox="0 0 24 24">
646
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
647
+ </svg>
648
+ {item.localCount}
649
+ </span>
650
+ )}
651
+ {item.cloudCount !== undefined && item.cloudCount > 0 && (
652
+ <span css={[styles.folderStat, styles.folderStatCloud]} title={`${item.cloudCount} in cloud`}>
653
+ <svg css={styles.folderStatIconCloud} fill="none" stroke="currentColor" viewBox="0 0 24 24">
654
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
655
+ </svg>
656
+ {item.cloudCount}
657
+ </span>
658
+ )}
659
+ {item.remoteCount !== undefined && item.remoteCount > 0 && (
660
+ <span css={[styles.folderStat, styles.folderStatRemote]} title={`${item.remoteCount} remote`}>
661
+ <svg css={styles.folderStatIconRemote} fill="none" stroke="currentColor" viewBox="0 0 24 24">
662
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
663
+ </svg>
664
+ {item.remoteCount}
665
+ </span>
666
+ )}
667
+ {!item.localCount && !item.cloudCount && !item.remoteCount && item.fileCount !== undefined && (
668
+ <span>{item.fileCount} files</span>
669
+ )}
670
+ {!item.localCount && !item.cloudCount && !item.remoteCount && item.fileCount === undefined && '--'}
671
+ </div>
672
+ ) : item.cdnPushed ? (
673
+ <span css={styles.storedLabel}>
674
+ <svg css={item.isRemote ? styles.storedIconRemote : styles.storedIconCloud} fill="none" stroke="currentColor" viewBox="0 0 24 24">
675
+ {item.isRemote ? (
676
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
677
+ ) : (
678
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
679
+ )}
680
+ </svg>
681
+ </span>
682
+ ) : (
683
+ item.size !== undefined ? formatFileSize(item.size) : '--'
684
+ )}
685
+ </td>
686
+ <td css={[styles.td, styles.meta]}>
687
+ {isFolder
688
+ ? (item.totalSize !== undefined ? formatFileSize(item.totalSize) : '--')
689
+ : (item.dimensions ? `${item.dimensions.width}x${item.dimensions.height}` : '--')
690
+ }
691
+ </td>
692
+ <td css={styles.td}>
693
+ {item.cdnPushed ? (
694
+ item.isRemote ? (
695
+ <span css={styles.cdnBadgeRemote}>Remote</span>
696
+ ) : (
697
+ <span css={styles.cdnBadge}>
698
+ <svg css={styles.cdnIcon} fill="currentColor" viewBox="0 0 20 20">
699
+ <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
700
+ </svg>
701
+ Pushed
702
+ </span>
703
+ )
704
+ ) : (
705
+ <span css={styles.cdnEmpty}>--</span>
706
+ )}
707
+ </td>
708
+ </tr>
709
+ )
710
+ }
711
+
712
+ function formatFileSize(bytes: number): string {
713
+ if (bytes < 1024) return `${bytes} B`
714
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
715
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
716
+ }
717
+
718
+ function getParentPath(path: string): string {
719
+ const parts = path.split('/')
720
+ parts.pop() // Remove current folder
721
+ return parts.join('/') + '/'
722
+ }
723
+
724
+ function truncateMiddle(str: string, maxLength: number = 32): string {
725
+ if (str.length <= maxLength) return str
726
+
727
+ // Find the extension
728
+ const lastDot = str.lastIndexOf('.')
729
+ const ext = lastDot > 0 ? str.substring(lastDot) : ''
730
+ const name = lastDot > 0 ? str.substring(0, lastDot) : str
731
+
732
+ // Calculate how much we can show of the name
733
+ const availableLength = maxLength - ext.length - 3 // 3 for "..."
734
+ if (availableLength < 6) {
735
+ // Too short, just truncate from end
736
+ return str.substring(0, maxLength - 3) + '...'
737
+ }
738
+
739
+ const startLength = Math.ceil(availableLength / 2)
740
+ const endLength = Math.floor(availableLength / 2)
741
+
742
+ return name.substring(0, startLength) + '...' + name.substring(name.length - endLength) + ext
743
+ }