@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,1448 @@
1
+ /** @jsxImportSource @emotion/react */
2
+ 'use client'
3
+
4
+ import { useCallback, useEffect, useRef, useState } from 'react'
5
+ import { css, keyframes } from '@emotion/react'
6
+ import { useStudio } from './StudioContext'
7
+ import { ConfirmModal, AlertModal, ProgressModal, InputModal, type ProgressState } from './StudioModal'
8
+ import { StudioFolderPicker } from './StudioFolderPicker'
9
+ import { R2SetupModal } from './R2SetupModal'
10
+ import { AddNewModal } from './AddNewModal'
11
+ import { colors, fontSize } from './tokens'
12
+
13
+ // Standard button height for consistency
14
+ const btnHeight = '36px'
15
+
16
+ const spin = keyframes`
17
+ to { transform: rotate(360deg); }
18
+ `
19
+
20
+ const styles = {
21
+ toolbar: css`
22
+ display: flex;
23
+ flex-wrap: nowrap;
24
+ align-items: center;
25
+ justify-content: space-between;
26
+ gap: 8px;
27
+ padding: 12px 16px;
28
+ background-color: ${colors.surface};
29
+ border-bottom: 1px solid ${colors.border};
30
+ overflow-x: auto;
31
+ min-width: 0;
32
+
33
+ @media (min-width: 768px) {
34
+ padding: 12px 24px;
35
+ }
36
+ `,
37
+ left: css`
38
+ display: flex;
39
+ flex-wrap: nowrap;
40
+ flex-shrink: 0;
41
+ align-items: center;
42
+ gap: 8px;
43
+ `,
44
+ right: css`
45
+ display: flex;
46
+ flex-wrap: nowrap;
47
+ flex-shrink: 0;
48
+ align-items: center;
49
+ gap: 8px;
50
+ `,
51
+ btn: css`
52
+ display: inline-flex;
53
+ align-items: center;
54
+ justify-content: center;
55
+ gap: 6px;
56
+ height: ${btnHeight};
57
+ padding: 0 14px;
58
+ border-radius: 6px;
59
+ font-size: ${fontSize.base};
60
+ font-weight: 500;
61
+ background: ${colors.surface};
62
+ border: 1px solid ${colors.border};
63
+ cursor: pointer;
64
+ transition: all 0.15s ease;
65
+ color: ${colors.text};
66
+ letter-spacing: -0.01em;
67
+
68
+ &:hover:not(:disabled) {
69
+ background-color: ${colors.surfaceHover};
70
+ border-color: ${colors.borderHover};
71
+ }
72
+
73
+ &:disabled {
74
+ cursor: not-allowed;
75
+ opacity: 0.5;
76
+ }
77
+ `,
78
+ btnIconOnly: css`
79
+ padding: 0 10px;
80
+ `,
81
+ btnPrimary: css`
82
+ background: ${colors.primary};
83
+ border-color: ${colors.primary};
84
+ color: white;
85
+
86
+ &:hover:not(:disabled) {
87
+ background: ${colors.primaryHover};
88
+ border-color: ${colors.primaryHover};
89
+ }
90
+ `,
91
+ btnDanger: css`
92
+ color: ${colors.danger};
93
+
94
+ &:hover:not(:disabled) {
95
+ background-color: ${colors.dangerLight};
96
+ border-color: ${colors.danger};
97
+ }
98
+ `,
99
+ icon: css`
100
+ width: 16px;
101
+ height: 16px;
102
+ `,
103
+ iconSpin: css`
104
+ animation: ${spin} 1s linear infinite;
105
+ `,
106
+ selectionCount: css`
107
+ font-size: ${fontSize.base};
108
+ color: ${colors.textSecondary};
109
+ display: flex;
110
+ align-items: center;
111
+ gap: 8px;
112
+ margin-right: 8px;
113
+ `,
114
+ clearBtn: css`
115
+ color: ${colors.primary};
116
+ background: none;
117
+ border: none;
118
+ cursor: pointer;
119
+ font-size: ${fontSize.base};
120
+ font-weight: 500;
121
+ padding: 0;
122
+
123
+ &:hover {
124
+ text-decoration: underline;
125
+ }
126
+ `,
127
+ divider: css`
128
+ width: 1px;
129
+ height: 24px;
130
+ background: ${colors.border};
131
+ margin: 0 4px;
132
+ `,
133
+ viewToggle: css`
134
+ display: flex;
135
+ align-items: center;
136
+ height: ${btnHeight};
137
+ background-color: ${colors.surface};
138
+ border: 1px solid ${colors.border};
139
+ border-radius: 6px;
140
+ overflow: hidden;
141
+ `,
142
+ searchWrapper: css`
143
+ position: relative;
144
+ display: flex;
145
+ align-items: center;
146
+ `,
147
+ searchInput: css`
148
+ height: ${btnHeight};
149
+ padding: 0 32px 0 12px;
150
+ border: 1px solid ${colors.border};
151
+ border-radius: 6px;
152
+ font-size: ${fontSize.base};
153
+ background: ${colors.surface};
154
+ color: ${colors.text};
155
+ width: 180px;
156
+ transition: all 0.15s ease;
157
+
158
+ &:focus {
159
+ outline: none;
160
+ border-color: ${colors.primary};
161
+ box-shadow: 0 0 0 2px ${colors.primaryLight};
162
+ }
163
+
164
+ &::placeholder {
165
+ color: ${colors.textMuted};
166
+ }
167
+ `,
168
+ searchClearBtn: css`
169
+ position: absolute;
170
+ right: 5px;
171
+ top: 5px;
172
+ bottom: 5px;
173
+ background: ${colors.primary};
174
+ border: none;
175
+ padding: 0 6px;
176
+ cursor: pointer;
177
+ color: white;
178
+ display: flex;
179
+ align-items: center;
180
+ justify-content: center;
181
+ border-radius: 4px;
182
+ transition: all 0.15s ease;
183
+
184
+ &:hover {
185
+ background: ${colors.primaryHover};
186
+ }
187
+ `,
188
+ viewBtn: css`
189
+ height: 100%;
190
+ padding: 0 10px;
191
+ background: transparent;
192
+ border: none;
193
+ cursor: pointer;
194
+ color: ${colors.textSecondary};
195
+ transition: all 0.15s ease;
196
+ display: flex;
197
+ align-items: center;
198
+ justify-content: center;
199
+
200
+ &:hover {
201
+ color: ${colors.text};
202
+ background-color: ${colors.surfaceHover};
203
+ }
204
+ `,
205
+ viewBtnActive: css`
206
+ background-color: ${colors.primaryLight};
207
+ color: ${colors.primary};
208
+
209
+ &:hover {
210
+ background-color: ${colors.primaryLight};
211
+ color: ${colors.primary};
212
+ }
213
+ `,
214
+ }
215
+
216
+ export function StudioToolbar() {
217
+ const { selectedItems, viewMode, setViewMode, clearSelection, currentPath, triggerRefresh, focusedItem, scanRequested, clearScanRequest, fileItems, requestProcess, actionState } = useStudio()
218
+ const fileInputRef = useRef<HTMLInputElement>(null)
219
+ const abortControllerRef = useRef<AbortController | null>(null)
220
+ const [showAddNewModal, setShowAddNewModal] = useState(false)
221
+ const [uploading, setUploading] = useState(false)
222
+ const [scanning, setScanning] = useState(false)
223
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
224
+ const [showSyncConfirm, setShowSyncConfirm] = useState(false)
225
+ const [syncImageCount, setSyncImageCount] = useState(0)
226
+ const [syncHasRemote, setSyncHasRemote] = useState(false)
227
+ const [syncHasLocal, setSyncHasLocal] = useState(false)
228
+ const [showDownloadConfirm, setShowDownloadConfirm] = useState(false)
229
+ const [downloadImageCount, setDownloadImageCount] = useState(0)
230
+ const [showProgress, setShowProgress] = useState(false)
231
+ const [progressTitle, setProgressTitle] = useState('Processing Images')
232
+ const [progressState, setProgressState] = useState<ProgressState>({
233
+ current: 0,
234
+ total: 0,
235
+ percent: 0,
236
+ status: 'processing',
237
+ })
238
+ const [alertMessage, setAlertMessage] = useState<{ title: string; message: string } | null>(null)
239
+ const [showNewFolderModal, setShowNewFolderModal] = useState(false)
240
+ const [showRenameFolderModal, setShowRenameFolderModal] = useState(false)
241
+ const [showMoveModal, setShowMoveModal] = useState(false)
242
+ const [showR2SetupModal, setShowR2SetupModal] = useState(false)
243
+ const [pushing, setPushing] = useState(false)
244
+
245
+ // Check if we're in the images folder (uploads not allowed there)
246
+ const isInImagesFolder = currentPath === 'public/images' || currentPath.startsWith('public/images/')
247
+
248
+ const handleUpload = useCallback(() => {
249
+ fileInputRef.current?.click()
250
+ }, [])
251
+
252
+ const handleScan = useCallback(async () => {
253
+ setScanning(true)
254
+ setProgressTitle('Scanning Files')
255
+ setShowProgress(true)
256
+ setProgressState({
257
+ current: 0,
258
+ total: 0,
259
+ percent: 0,
260
+ status: 'processing',
261
+ message: 'Scanning for files...',
262
+ })
263
+
264
+ try {
265
+ const response = await fetch('/api/studio/scan', { method: 'POST' })
266
+ const reader = response.body?.getReader()
267
+ if (!reader) throw new Error('No reader')
268
+
269
+ const decoder = new TextDecoder()
270
+ let buffer = ''
271
+
272
+ while (true) {
273
+ const { done, value } = await reader.read()
274
+ if (done) break
275
+
276
+ buffer += decoder.decode(value, { stream: true })
277
+ const lines = buffer.split('\n\n')
278
+ buffer = lines.pop() || ''
279
+
280
+ for (const line of lines) {
281
+ if (!line.startsWith('data: ')) continue
282
+ const data = JSON.parse(line.slice(6))
283
+
284
+ if (data.type === 'start') {
285
+ setProgressState({
286
+ current: 0,
287
+ total: data.total,
288
+ percent: 0,
289
+ status: 'processing',
290
+ message: `Scanning ${data.total} files...`,
291
+ })
292
+ } else if (data.type === 'progress') {
293
+ setProgressState({
294
+ current: data.current,
295
+ total: data.total,
296
+ percent: data.percent,
297
+ status: 'processing',
298
+ currentFile: data.currentFile,
299
+ })
300
+ } else if (data.type === 'cleanup') {
301
+ setProgressState(prev => ({
302
+ ...prev,
303
+ message: data.message,
304
+ }))
305
+ } else if (data.type === 'complete') {
306
+ let message = data.renamed > 0 ? `${data.renamed} file(s) renamed due to conflicts. ` : ''
307
+ if (data.orphanedFiles && data.orphanedFiles.length > 0) {
308
+ message += `Found ${data.orphanedFiles.length} orphaned thumbnail(s) in images folder.`
309
+ }
310
+ setProgressState({
311
+ current: data.total || 0,
312
+ total: data.total || 0,
313
+ percent: 100,
314
+ status: 'complete',
315
+ processed: data.added,
316
+ alreadyProcessed: data.existingCount,
317
+ errors: data.errors,
318
+ orphanedFiles: data.orphanedFiles,
319
+ message: message || undefined,
320
+ isScan: true,
321
+ })
322
+ triggerRefresh()
323
+ } else if (data.type === 'error') {
324
+ setProgressState({
325
+ current: 0,
326
+ total: 0,
327
+ percent: 0,
328
+ status: 'error',
329
+ message: data.message || 'Scan failed',
330
+ })
331
+ }
332
+ }
333
+ }
334
+ } catch (error) {
335
+ console.error('Scan error:', error)
336
+ setProgressState({
337
+ current: 0,
338
+ total: 0,
339
+ percent: 0,
340
+ status: 'error',
341
+ message: 'Scan failed',
342
+ })
343
+ } finally {
344
+ setScanning(false)
345
+ }
346
+ }, [triggerRefresh])
347
+
348
+ // Handle scan request from file pane
349
+ useEffect(() => {
350
+ if (scanRequested && !scanning) {
351
+ clearScanRequest()
352
+ handleScan()
353
+ }
354
+ }, [scanRequested, scanning, clearScanRequest, handleScan])
355
+
356
+ const handleFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
357
+ const files = e.target.files
358
+ if (!files || files.length === 0) return
359
+
360
+ const fileList = Array.from(files)
361
+
362
+ // Show progress modal for multiple files
363
+ if (fileList.length > 1) {
364
+ setProgressState({
365
+ current: 0,
366
+ total: fileList.length,
367
+ percent: 0,
368
+ status: 'processing',
369
+ message: 'Uploading files...',
370
+ })
371
+ setShowProgress(true)
372
+ } else {
373
+ setUploading(true)
374
+ }
375
+
376
+ let uploaded = 0
377
+ let errors = 0
378
+
379
+ try {
380
+ for (let i = 0; i < fileList.length; i++) {
381
+ const file = fileList[i]
382
+
383
+ if (fileList.length > 1) {
384
+ setProgressState({
385
+ current: i + 1,
386
+ total: fileList.length,
387
+ percent: Math.round(((i + 1) / fileList.length) * 100),
388
+ status: 'processing',
389
+ currentFile: file.name,
390
+ })
391
+ }
392
+
393
+ const formData = new FormData()
394
+ formData.append('file', file)
395
+ formData.append('path', currentPath)
396
+
397
+ try {
398
+ const response = await fetch('/api/studio/upload', {
399
+ method: 'POST',
400
+ body: formData,
401
+ })
402
+
403
+ if (!response.ok) {
404
+ const error = await response.json()
405
+ errors++
406
+ if (fileList.length === 1) {
407
+ if (response.status >= 500) {
408
+ console.error('Upload error:', error)
409
+ setAlertMessage({
410
+ title: 'Upload Failed',
411
+ message: `Failed to upload ${file.name}: ${error.error || 'Unknown error'}`,
412
+ })
413
+ } else {
414
+ setAlertMessage({
415
+ title: 'Cannot Upload Here',
416
+ message: error.error || 'Upload not allowed in this location.',
417
+ })
418
+ }
419
+ }
420
+ } else {
421
+ uploaded++
422
+ }
423
+ } catch {
424
+ errors++
425
+ }
426
+ }
427
+
428
+ if (fileList.length > 1) {
429
+ setProgressState({
430
+ current: fileList.length,
431
+ total: fileList.length,
432
+ percent: 100,
433
+ status: 'complete',
434
+ processed: uploaded,
435
+ errors: errors,
436
+ })
437
+ }
438
+
439
+ triggerRefresh()
440
+ } catch (error) {
441
+ console.error('Upload error:', error)
442
+ if (fileList.length > 1) {
443
+ setProgressState({
444
+ current: 0,
445
+ total: 0,
446
+ percent: 0,
447
+ status: 'error',
448
+ message: 'Upload failed.',
449
+ })
450
+ } else {
451
+ setAlertMessage({
452
+ title: 'Upload Failed',
453
+ message: 'Upload failed. Check console for details.',
454
+ })
455
+ }
456
+ } finally {
457
+ setUploading(false)
458
+ if (fileInputRef.current) {
459
+ fileInputRef.current.value = ''
460
+ }
461
+ }
462
+ }, [currentPath, triggerRefresh])
463
+
464
+ const handleProcessImages = useCallback(async () => {
465
+ const hasSelection = selectedItems.size > 0
466
+
467
+ if (hasSelection) {
468
+ const selectedPaths = Array.from(selectedItems)
469
+
470
+ // Separate folders and files (files have extensions)
471
+ const selectedFilePaths = selectedPaths.filter(p => {
472
+ const lastPart = p.split('/').pop() || ''
473
+ return lastPart.includes('.') && !p.endsWith('/')
474
+ })
475
+ const selectedFolders = selectedPaths.filter(p => {
476
+ const lastPart = p.split('/').pop() || ''
477
+ return !lastPart.includes('.') || p.endsWith('/')
478
+ })
479
+
480
+ // If folders are selected, fetch all files from them
481
+ if (selectedFolders.length > 0) {
482
+ try {
483
+ const response = await fetch(`/api/studio/folder-images?folders=${encodeURIComponent(selectedFolders.join(','))}`)
484
+ const data = await response.json()
485
+
486
+ if (data.images) {
487
+ // Add folder files to selectedFilePaths (as public/ paths)
488
+ for (const img of data.images) {
489
+ const fullPath = `public/${img}`
490
+ if (!selectedFilePaths.includes(fullPath)) {
491
+ selectedFilePaths.push(fullPath)
492
+ }
493
+ }
494
+ }
495
+ } catch (error) {
496
+ console.error('Failed to get folder files:', error)
497
+ }
498
+ }
499
+
500
+ if (selectedFilePaths.length === 0) {
501
+ setAlertMessage({
502
+ title: 'No Files Found',
503
+ message: 'No files found in the selected items.',
504
+ })
505
+ return
506
+ }
507
+
508
+ // Use shared process modal
509
+ requestProcess(selectedFilePaths)
510
+ } else {
511
+ // Get ALL image paths for "process all"
512
+ try {
513
+ const response = await fetch('/api/studio/count-images')
514
+ const data = await response.json()
515
+
516
+ if (!data.images || data.images.length === 0) {
517
+ setAlertMessage({
518
+ title: 'No Images Found',
519
+ message: 'No images found in the public folder to process.',
520
+ })
521
+ return
522
+ }
523
+
524
+ // Convert to full paths and use shared process modal
525
+ const allImagePaths = data.images.map((img: string) => `public/${img}`)
526
+ requestProcess(allImagePaths)
527
+ } catch (error) {
528
+ console.error('Failed to get images:', error)
529
+ setAlertMessage({
530
+ title: 'Error',
531
+ message: 'Failed to get images.',
532
+ })
533
+ }
534
+ }
535
+ }, [selectedItems, requestProcess])
536
+
537
+ const handleStopProcessing = useCallback(() => {
538
+ if (abortControllerRef.current) {
539
+ abortControllerRef.current.abort()
540
+ }
541
+ }, [])
542
+
543
+ const handleDeleteOrphans = useCallback(async () => {
544
+ const orphanedFiles = progressState.orphanedFiles
545
+ if (!orphanedFiles || orphanedFiles.length === 0) return
546
+
547
+ try {
548
+ const response = await fetch('/api/studio/delete-orphans', {
549
+ method: 'POST',
550
+ headers: { 'Content-Type': 'application/json' },
551
+ body: JSON.stringify({ paths: orphanedFiles }),
552
+ })
553
+
554
+ const data = await response.json()
555
+
556
+ if (response.ok) {
557
+ setProgressState(prev => ({
558
+ ...prev,
559
+ orphanedFiles: undefined,
560
+ orphansRemoved: data.deleted,
561
+ message: `Deleted ${data.deleted} orphaned thumbnail${data.deleted !== 1 ? 's' : ''}.`,
562
+ }))
563
+ triggerRefresh()
564
+ } else {
565
+ setAlertMessage({
566
+ title: 'Delete Failed',
567
+ message: data.error || 'Failed to delete orphaned files.',
568
+ })
569
+ }
570
+ } catch (error) {
571
+ console.error('Delete orphans error:', error)
572
+ setAlertMessage({
573
+ title: 'Delete Failed',
574
+ message: 'Failed to delete orphaned files. Check console for details.',
575
+ })
576
+ }
577
+ }, [progressState.orphanedFiles, triggerRefresh])
578
+
579
+ const handleDeleteClick = useCallback(() => {
580
+ if (selectedItems.size === 0) return
581
+ setShowDeleteConfirm(true)
582
+ }, [selectedItems])
583
+
584
+ const handleDeleteConfirm = useCallback(async () => {
585
+ setShowDeleteConfirm(false)
586
+
587
+ try {
588
+ const response = await fetch('/api/studio/delete', {
589
+ method: 'POST',
590
+ headers: { 'Content-Type': 'application/json' },
591
+ body: JSON.stringify({ paths: Array.from(selectedItems) }),
592
+ })
593
+
594
+ if (response.ok) {
595
+ clearSelection()
596
+ triggerRefresh()
597
+ } else {
598
+ const error = await response.json()
599
+ setAlertMessage({
600
+ title: 'Delete Failed',
601
+ message: error.error || 'Unknown error',
602
+ })
603
+ }
604
+ } catch (error) {
605
+ console.error('Delete error:', error)
606
+ setAlertMessage({
607
+ title: 'Delete Failed',
608
+ message: 'Delete failed. Check console for details.',
609
+ })
610
+ }
611
+ }, [selectedItems, clearSelection, triggerRefresh])
612
+
613
+ const handleSyncClick = useCallback(async () => {
614
+ if (selectedItems.size === 0) return
615
+
616
+ const selectedPaths = Array.from(selectedItems)
617
+
618
+ // Separate folders and files (files have extensions)
619
+ const selectedFilePaths = selectedPaths.filter(p => {
620
+ const lastPart = p.split('/').pop() || ''
621
+ return lastPart.includes('.') && !p.endsWith('/')
622
+ })
623
+ const selectedFolders = selectedPaths.filter(p => {
624
+ const lastPart = p.split('/').pop() || ''
625
+ return !lastPart.includes('.') || p.endsWith('/')
626
+ })
627
+
628
+ // If folders are selected, fetch all files from them
629
+ if (selectedFolders.length > 0) {
630
+ try {
631
+ const response = await fetch(`/api/studio/folder-images?folders=${encodeURIComponent(selectedFolders.join(','))}`)
632
+ const data = await response.json()
633
+
634
+ if (data.images) {
635
+ for (const img of data.images) {
636
+ const fullPath = `public/${img}`
637
+ if (!selectedFilePaths.includes(fullPath)) {
638
+ selectedFilePaths.push(fullPath)
639
+ }
640
+ }
641
+ }
642
+ } catch (error) {
643
+ console.error('Failed to get folder files:', error)
644
+ }
645
+ }
646
+
647
+ if (selectedFilePaths.length === 0) {
648
+ setAlertMessage({
649
+ title: 'No Files Found',
650
+ message: 'No files found in the selected items.',
651
+ })
652
+ return
653
+ }
654
+
655
+ // Determine what types of files are selected
656
+ let hasRemote = false
657
+ let hasLocal = false
658
+
659
+ for (const filePath of selectedFilePaths) {
660
+ const item = fileItems.find(f => f.path === filePath)
661
+ if (item) {
662
+ if (item.isRemote) {
663
+ hasRemote = true
664
+ } else if (!item.cdnPushed) {
665
+ hasLocal = true
666
+ }
667
+ }
668
+ }
669
+
670
+ // Store info and show confirm modal
671
+ setSyncImageCount(selectedFilePaths.length)
672
+ setSyncHasRemote(hasRemote)
673
+ setSyncHasLocal(hasLocal)
674
+ setShowSyncConfirm(true)
675
+ }, [selectedItems, fileItems])
676
+
677
+ const handleSyncConfirm = useCallback(async () => {
678
+ setShowSyncConfirm(false)
679
+
680
+ const selectedPaths = Array.from(selectedItems)
681
+
682
+ // Separate folders and files (files have extensions)
683
+ const selectedFilePaths = selectedPaths.filter(p => {
684
+ const lastPart = p.split('/').pop() || ''
685
+ return lastPart.includes('.') && !p.endsWith('/')
686
+ })
687
+ const selectedFolders = selectedPaths.filter(p => {
688
+ const lastPart = p.split('/').pop() || ''
689
+ return !lastPart.includes('.') || p.endsWith('/')
690
+ })
691
+
692
+ // If folders are selected, fetch all files from them
693
+ if (selectedFolders.length > 0) {
694
+ try {
695
+ const response = await fetch(`/api/studio/folder-images?folders=${encodeURIComponent(selectedFolders.join(','))}`)
696
+ const data = await response.json()
697
+
698
+ if (data.images) {
699
+ for (const img of data.images) {
700
+ const fullPath = `public/${img}`
701
+ if (!selectedFilePaths.includes(fullPath)) {
702
+ selectedFilePaths.push(fullPath)
703
+ }
704
+ }
705
+ }
706
+ } catch (error) {
707
+ console.error('Failed to get folder files:', error)
708
+ }
709
+ }
710
+
711
+ // Convert to file keys
712
+ const imageKeys = selectedFilePaths.map(p => '/' + p.replace(/^public\//, ''))
713
+
714
+ // Show progress modal
715
+ setProgressTitle('Pushing to CDN')
716
+ setProgressState({
717
+ current: 0,
718
+ total: imageKeys.length,
719
+ percent: 0,
720
+ status: 'processing',
721
+ message: 'Pushing to CDN...',
722
+ })
723
+ setShowProgress(true)
724
+
725
+ let pushed = 0
726
+ let errors = 0
727
+ const errorMessages: string[] = []
728
+
729
+ try {
730
+ // Push images one by one for progress tracking
731
+ for (let i = 0; i < imageKeys.length; i++) {
732
+ const imageKey = imageKeys[i]
733
+
734
+ setProgressState({
735
+ current: i + 1,
736
+ total: imageKeys.length,
737
+ percent: Math.round(((i + 1) / imageKeys.length) * 100),
738
+ status: 'processing',
739
+ currentFile: imageKey.replace(/^\//, ''),
740
+ })
741
+
742
+ // Retry logic for transient network errors
743
+ let success = false
744
+ let lastError: string | undefined
745
+
746
+ for (let attempt = 0; attempt < 3 && !success; attempt++) {
747
+ try {
748
+ const response = await fetch('/api/studio/sync', {
749
+ method: 'POST',
750
+ headers: { 'Content-Type': 'application/json' },
751
+ body: JSON.stringify({ imageKeys: [imageKey] }),
752
+ })
753
+
754
+ const data = await response.json()
755
+
756
+ if (!response.ok) {
757
+ // Check if it's an R2 configuration error
758
+ if (data.error?.includes('R2 not configured') || data.error?.includes('CLOUDFLARE_R2')) {
759
+ setShowProgress(false)
760
+ setShowR2SetupModal(true)
761
+ return
762
+ }
763
+ lastError = data.error || `Failed: ${imageKey}`
764
+ } else if (data.pushed?.length > 0) {
765
+ pushed++
766
+ success = true
767
+ } else if (data.errors?.length > 0) {
768
+ // Server-side errors from handler
769
+ for (const errMsg of data.errors) {
770
+ lastError = errMsg
771
+ }
772
+ } else {
773
+ // Already pushed or no action needed
774
+ success = true
775
+ }
776
+ } catch (err) {
777
+ lastError = `Network error: ${imageKey}`
778
+ // Wait before retry (exponential backoff: 500ms, 1s)
779
+ if (attempt < 2) {
780
+ await new Promise(resolve => setTimeout(resolve, 500 * (attempt + 1)))
781
+ }
782
+ }
783
+ }
784
+
785
+ if (!success && lastError) {
786
+ errors++
787
+ errorMessages.push(lastError)
788
+ }
789
+ }
790
+
791
+ setProgressState({
792
+ current: imageKeys.length,
793
+ total: imageKeys.length,
794
+ percent: 100,
795
+ status: 'complete',
796
+ processed: pushed,
797
+ errors: errors,
798
+ errorMessages: errorMessages.length > 0 ? errorMessages : undefined,
799
+ })
800
+
801
+ clearSelection()
802
+ triggerRefresh()
803
+ } catch (error) {
804
+ console.error('Push error:', error)
805
+ setProgressState({
806
+ current: 0,
807
+ total: 0,
808
+ percent: 0,
809
+ status: 'error',
810
+ message: 'Failed to push to CDN.',
811
+ })
812
+ }
813
+ }, [selectedItems, clearSelection, triggerRefresh])
814
+
815
+ // Download from R2 to local
816
+ const handleDownloadClick = useCallback(async () => {
817
+ if (selectedItems.size === 0) return
818
+
819
+ const selectedPaths = Array.from(selectedItems)
820
+
821
+ // Get only files (not folders)
822
+ const selectedFilePaths = selectedPaths.filter(p => {
823
+ const lastPart = p.split('/').pop() || ''
824
+ return lastPart.includes('.') && !p.endsWith('/')
825
+ })
826
+
827
+ if (selectedFilePaths.length === 0) {
828
+ setAlertMessage({
829
+ title: 'No Files Found',
830
+ message: 'No files found in the selected items.',
831
+ })
832
+ return
833
+ }
834
+
835
+ setDownloadImageCount(selectedFilePaths.length)
836
+ setShowDownloadConfirm(true)
837
+ }, [selectedItems])
838
+
839
+ const handleDownloadConfirm = useCallback(async () => {
840
+ setShowDownloadConfirm(false)
841
+
842
+ const selectedPaths = Array.from(selectedItems)
843
+
844
+ // Get only files (not folders)
845
+ const selectedFilePaths = selectedPaths.filter(p => {
846
+ const lastPart = p.split('/').pop() || ''
847
+ return lastPart.includes('.') && !p.endsWith('/')
848
+ })
849
+
850
+ // Convert to file keys
851
+ const imageKeys = selectedFilePaths.map(p => '/' + p.replace(/^public\//, ''))
852
+
853
+ // Show progress modal
854
+ setProgressTitle('Downloading from CDN')
855
+ setShowProgress(true)
856
+ setProgressState({
857
+ current: 0,
858
+ total: imageKeys.length,
859
+ percent: 0,
860
+ status: 'processing',
861
+ })
862
+
863
+ try {
864
+ const response = await fetch('/api/studio/download-stream', {
865
+ method: 'POST',
866
+ headers: { 'Content-Type': 'application/json' },
867
+ body: JSON.stringify({ imageKeys }),
868
+ })
869
+
870
+ if (!response.ok || !response.body) {
871
+ throw new Error('Download request failed')
872
+ }
873
+
874
+ const reader = response.body.getReader()
875
+ const decoder = new TextDecoder()
876
+ let buffer = ''
877
+
878
+ while (true) {
879
+ const { done, value } = await reader.read()
880
+ if (done) break
881
+
882
+ buffer += decoder.decode(value, { stream: true })
883
+ const lines = buffer.split('\n')
884
+ buffer = lines.pop() || ''
885
+
886
+ for (const line of lines) {
887
+ if (line.startsWith('data: ')) {
888
+ try {
889
+ const data = JSON.parse(line.slice(6))
890
+ if (data.type === 'progress') {
891
+ setProgressState({
892
+ current: data.current,
893
+ total: data.total,
894
+ percent: Math.round((data.current / data.total) * 100),
895
+ status: 'processing',
896
+ message: data.message,
897
+ })
898
+ } else if (data.type === 'complete') {
899
+ setProgressState({
900
+ current: data.total || imageKeys.length,
901
+ total: data.total || imageKeys.length,
902
+ percent: 100,
903
+ status: 'complete',
904
+ message: data.message,
905
+ })
906
+ } else if (data.type === 'error') {
907
+ setProgressState({
908
+ current: 0,
909
+ total: 0,
910
+ percent: 0,
911
+ status: 'error',
912
+ message: data.message,
913
+ })
914
+ }
915
+ } catch {
916
+ // Ignore parse errors
917
+ }
918
+ }
919
+ }
920
+ }
921
+
922
+ clearSelection()
923
+ triggerRefresh()
924
+ } catch (error) {
925
+ console.error('Download error:', error)
926
+ setProgressState({
927
+ current: 0,
928
+ total: 0,
929
+ percent: 0,
930
+ status: 'error',
931
+ message: 'Failed to download from CDN.',
932
+ })
933
+ }
934
+ }, [selectedItems, clearSelection, triggerRefresh])
935
+
936
+ const handleCreateFolder = useCallback(async (folderName: string) => {
937
+ setShowNewFolderModal(false)
938
+
939
+ try {
940
+ const response = await fetch('/api/studio/create-folder', {
941
+ method: 'POST',
942
+ headers: { 'Content-Type': 'application/json' },
943
+ body: JSON.stringify({ parentPath: currentPath, name: folderName }),
944
+ })
945
+
946
+ if (response.ok) {
947
+ triggerRefresh()
948
+ } else {
949
+ const error = await response.json()
950
+ setAlertMessage({
951
+ title: 'Create Folder Failed',
952
+ message: error.error || 'Unknown error',
953
+ })
954
+ }
955
+ } catch (error) {
956
+ console.error('Create folder error:', error)
957
+ setAlertMessage({
958
+ title: 'Create Folder Failed',
959
+ message: 'Failed to create folder. Check console for details.',
960
+ })
961
+ }
962
+ }, [currentPath, triggerRefresh])
963
+
964
+ const handleMoveClick = useCallback(() => {
965
+ if (selectedItems.size === 0) return
966
+ setShowMoveModal(true)
967
+ }, [selectedItems])
968
+
969
+ const handleMoveConfirm = useCallback(async (destination: string) => {
970
+ const paths = Array.from(selectedItems)
971
+
972
+ // Show progress modal
973
+ setProgressTitle('Moving Files')
974
+ setShowProgress(true)
975
+ setProgressState({
976
+ current: 0,
977
+ total: paths.length,
978
+ percent: 0,
979
+ status: 'processing',
980
+ })
981
+
982
+ try {
983
+ const response = await fetch('/api/studio/move', {
984
+ method: 'POST',
985
+ headers: { 'Content-Type': 'application/json' },
986
+ body: JSON.stringify({ paths, destination }),
987
+ })
988
+
989
+ if (!response.body) {
990
+ throw new Error('No response body')
991
+ }
992
+
993
+ const reader = response.body.getReader()
994
+ const decoder = new TextDecoder()
995
+ let buffer = ''
996
+
997
+ while (true) {
998
+ const { done, value } = await reader.read()
999
+ if (done) break
1000
+
1001
+ buffer += decoder.decode(value, { stream: true })
1002
+ const lines = buffer.split('\n\n')
1003
+ buffer = lines.pop() || ''
1004
+
1005
+ for (const line of lines) {
1006
+ if (!line.startsWith('data: ')) continue
1007
+ try {
1008
+ const data = JSON.parse(line.slice(6))
1009
+
1010
+ if (data.type === 'start') {
1011
+ setProgressState(prev => ({ ...prev, total: data.total }))
1012
+ } else if (data.type === 'progress') {
1013
+ setProgressState({
1014
+ current: data.current,
1015
+ total: data.total,
1016
+ percent: data.percent,
1017
+ currentFile: data.currentFile,
1018
+ status: 'processing',
1019
+ })
1020
+ } else if (data.type === 'complete') {
1021
+ setProgressState(prev => ({
1022
+ ...prev,
1023
+ status: 'complete',
1024
+ processed: data.moved,
1025
+ errors: data.errors,
1026
+ errorMessages: data.errorMessages,
1027
+ isMove: true,
1028
+ }))
1029
+ clearSelection()
1030
+ triggerRefresh()
1031
+ } else if (data.type === 'error') {
1032
+ setProgressState(prev => ({
1033
+ ...prev,
1034
+ status: 'error',
1035
+ errorMessage: data.message,
1036
+ }))
1037
+ }
1038
+ } catch {
1039
+ // Ignore parse errors
1040
+ }
1041
+ }
1042
+ }
1043
+ } catch (error) {
1044
+ console.error('Move error:', error)
1045
+ setProgressState(prev => ({
1046
+ ...prev,
1047
+ status: 'error',
1048
+ errorMessage: 'Failed to move items. Check console for details.',
1049
+ }))
1050
+ }
1051
+ }, [selectedItems, clearSelection, triggerRefresh])
1052
+
1053
+ const { searchQuery, setSearchQuery } = useStudio()
1054
+
1055
+ const handleSearch = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
1056
+ setSearchQuery(e.target.value)
1057
+ }, [setSearchQuery])
1058
+
1059
+ const handleSearchKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
1060
+ if (e.key === 'Escape') {
1061
+ e.stopPropagation() // Prevent closing the studio
1062
+ setSearchQuery('')
1063
+ ;(e.target as HTMLInputElement).blur()
1064
+ }
1065
+ }, [setSearchQuery])
1066
+
1067
+ const hasSelection = selectedItems.size > 0
1068
+
1069
+ // Check if any selected items are already in our R2 (for Push CDN disabling)
1070
+ // Remote images (from other CDNs) can still be pushed to our R2
1071
+ const hasR2Selection = hasSelection && Array.from(selectedItems).some(path => {
1072
+ const item = fileItems.find(f => f.path === path)
1073
+ return item && item.cdnPushed && !item.isRemote
1074
+ })
1075
+
1076
+ // Check if ALL selected items are R2 cloud files (for Download button)
1077
+ const allR2Selection = hasSelection && Array.from(selectedItems).every(path => {
1078
+ const item = fileItems.find(f => f.path === path)
1079
+ // Only file items, not folders, and must be on R2 (cdnPushed && !isRemote)
1080
+ return item && item.type === 'file' && item.cdnPushed && !item.isRemote
1081
+ })
1082
+
1083
+ // Check if exactly one folder is selected (for rename)
1084
+ const selectedPaths = Array.from(selectedItems)
1085
+ const singleFolderSelected = selectedPaths.length === 1 && !selectedPaths[0].includes('.')
1086
+ const selectedFolderPath = singleFolderSelected ? selectedPaths[0] : null
1087
+ const selectedFolderName = selectedFolderPath ? selectedFolderPath.split('/').pop() || '' : ''
1088
+
1089
+ const handleRenameFolder = useCallback(async (newName: string) => {
1090
+ if (!selectedFolderPath) return
1091
+ setShowRenameFolderModal(false)
1092
+ try {
1093
+ const response = await fetch('/api/studio/rename', {
1094
+ method: 'POST',
1095
+ headers: { 'Content-Type': 'application/json' },
1096
+ body: JSON.stringify({ oldPath: selectedFolderPath, newName }),
1097
+ })
1098
+ if (response.ok) {
1099
+ clearSelection()
1100
+ triggerRefresh()
1101
+ }
1102
+ } catch (error) {
1103
+ console.error('Failed to rename folder:', error)
1104
+ }
1105
+ }, [selectedFolderPath, clearSelection, triggerRefresh])
1106
+
1107
+ // Hide toolbar actions when viewing detail
1108
+ if (focusedItem) {
1109
+ return null
1110
+ }
1111
+
1112
+ return (
1113
+ <>
1114
+ {showDeleteConfirm && (
1115
+ <ConfirmModal
1116
+ title="Delete Items"
1117
+ message={`Are you sure you want to delete ${selectedItems.size} item(s)? This action cannot be undone.`}
1118
+ confirmLabel="Delete"
1119
+ variant="danger"
1120
+ onConfirm={handleDeleteConfirm}
1121
+ onCancel={() => setShowDeleteConfirm(false)}
1122
+ />
1123
+ )}
1124
+
1125
+ {showSyncConfirm && (
1126
+ <ConfirmModal
1127
+ title="Push to CDN"
1128
+ message={`Push ${syncImageCount} image${syncImageCount !== 1 ? 's' : ''} to Cloudflare R2?${syncHasRemote ? ' Remote images will be downloaded first.' : ''}${syncHasLocal ? ' After pushing, local files will be deleted.' : ''}`}
1129
+ confirmLabel="Push"
1130
+ onConfirm={handleSyncConfirm}
1131
+ onCancel={() => setShowSyncConfirm(false)}
1132
+ />
1133
+ )}
1134
+
1135
+ {showDownloadConfirm && (
1136
+ <ConfirmModal
1137
+ title="Download from CDN"
1138
+ message={`Download ${downloadImageCount} image${downloadImageCount !== 1 ? 's' : ''} from Cloudflare R2 to local storage? Images will be removed from the CDN.`}
1139
+ confirmLabel="Download"
1140
+ onConfirm={handleDownloadConfirm}
1141
+ onCancel={() => setShowDownloadConfirm(false)}
1142
+ />
1143
+ )}
1144
+
1145
+ {showProgress && (
1146
+ <ProgressModal
1147
+ title={progressTitle}
1148
+ progress={progressState}
1149
+ onStop={handleStopProcessing}
1150
+ onDeleteOrphans={handleDeleteOrphans}
1151
+ onClose={() => {
1152
+ setShowProgress(false)
1153
+ setProgressState({
1154
+ current: 0,
1155
+ total: 0,
1156
+ percent: 0,
1157
+ status: 'processing',
1158
+ })
1159
+ }}
1160
+ />
1161
+ )}
1162
+
1163
+ {showNewFolderModal && (
1164
+ <InputModal
1165
+ title="New Folder"
1166
+ message="Enter a name for the new folder:"
1167
+ placeholder="Folder name"
1168
+ confirmLabel="Create"
1169
+ onConfirm={handleCreateFolder}
1170
+ onCancel={() => setShowNewFolderModal(false)}
1171
+ />
1172
+ )}
1173
+
1174
+ {showMoveModal && (
1175
+ <StudioFolderPicker
1176
+ selectedItems={selectedItems}
1177
+ currentPath={currentPath}
1178
+ onMove={(destination) => {
1179
+ setShowMoveModal(false)
1180
+ handleMoveConfirm(destination)
1181
+ }}
1182
+ onCancel={() => setShowMoveModal(false)}
1183
+ />
1184
+ )}
1185
+
1186
+ {showRenameFolderModal && selectedFolderPath && (
1187
+ <InputModal
1188
+ title="Rename Folder"
1189
+ message="Enter a new name for the folder:"
1190
+ placeholder={selectedFolderName}
1191
+ defaultValue={selectedFolderName}
1192
+ confirmLabel="Rename"
1193
+ onConfirm={handleRenameFolder}
1194
+ onCancel={() => setShowRenameFolderModal(false)}
1195
+ />
1196
+ )}
1197
+
1198
+ {alertMessage && (
1199
+ <AlertModal
1200
+ title={alertMessage.title}
1201
+ message={alertMessage.message}
1202
+ onClose={() => setAlertMessage(null)}
1203
+ />
1204
+ )}
1205
+
1206
+ <R2SetupModal
1207
+ isOpen={showR2SetupModal}
1208
+ onClose={() => setShowR2SetupModal(false)}
1209
+ />
1210
+
1211
+ {showAddNewModal && (
1212
+ <AddNewModal
1213
+ currentPath={currentPath}
1214
+ onClose={() => setShowAddNewModal(false)}
1215
+ onUploadComplete={() => {
1216
+ setShowAddNewModal(false)
1217
+ triggerRefresh()
1218
+ }}
1219
+ />
1220
+ )}
1221
+
1222
+ <div css={styles.toolbar}>
1223
+ <input
1224
+ ref={fileInputRef}
1225
+ type="file"
1226
+ multiple
1227
+ accept="image/*,video/*,audio/*,.pdf"
1228
+ onChange={handleFileChange}
1229
+ style={{ display: 'none' }}
1230
+ />
1231
+
1232
+ <div css={styles.left}>
1233
+ <button
1234
+ css={[styles.btn, styles.btnPrimary]}
1235
+ onClick={() => setShowAddNewModal(true)}
1236
+ disabled={uploading || isInImagesFolder}
1237
+ >
1238
+ <UploadIcon />
1239
+ Add New
1240
+ </button>
1241
+ <button
1242
+ css={styles.btn}
1243
+ onClick={() => singleFolderSelected ? setShowRenameFolderModal(true) : setShowNewFolderModal(true)}
1244
+ disabled={isInImagesFolder && !singleFolderSelected}
1245
+ title={isInImagesFolder && !singleFolderSelected ? 'Cannot create folders in protected images folder' : undefined}
1246
+ >
1247
+ {singleFolderSelected ? <RenameIcon /> : <FolderPlusIcon />}
1248
+ {singleFolderSelected ? 'Rename Folder' : 'New Folder'}
1249
+ </button>
1250
+
1251
+ <div css={styles.divider} />
1252
+
1253
+ <button
1254
+ css={styles.btn}
1255
+ onClick={handleProcessImages}
1256
+ disabled={actionState.showProgress || isInImagesFolder}
1257
+ title={isInImagesFolder ? 'Cannot process images folder' : undefined}
1258
+ >
1259
+ <ImageStackIcon />
1260
+ Process Images
1261
+ </button>
1262
+ <button
1263
+ css={[styles.btn, styles.btnDanger]}
1264
+ onClick={handleDeleteClick}
1265
+ disabled={!hasSelection}
1266
+ >
1267
+ <TrashIcon />
1268
+ Delete
1269
+ </button>
1270
+ <button
1271
+ css={styles.btn}
1272
+ onClick={handleMoveClick}
1273
+ disabled={!hasSelection}
1274
+ >
1275
+ <MoveIcon />
1276
+ Move
1277
+ </button>
1278
+ {allR2Selection ? (
1279
+ <button
1280
+ css={styles.btn}
1281
+ onClick={handleDownloadClick}
1282
+ disabled={!hasSelection}
1283
+ >
1284
+ <CloudDownloadIcon />
1285
+ Download
1286
+ </button>
1287
+ ) : (
1288
+ <button
1289
+ css={styles.btn}
1290
+ onClick={handleSyncClick}
1291
+ disabled={!hasSelection || hasR2Selection}
1292
+ title={hasR2Selection ? 'Selected files are already in R2' : undefined}
1293
+ >
1294
+ <CloudIcon />
1295
+ Push CDN
1296
+ </button>
1297
+ )}
1298
+ <div css={styles.searchWrapper}>
1299
+ <input
1300
+ css={styles.searchInput}
1301
+ type="text"
1302
+ placeholder="Search images..."
1303
+ value={searchQuery}
1304
+ onChange={handleSearch}
1305
+ onKeyDown={handleSearchKeyDown}
1306
+ />
1307
+ {searchQuery && (
1308
+ <button
1309
+ css={styles.searchClearBtn}
1310
+ onClick={() => setSearchQuery('')}
1311
+ title="Clear search"
1312
+ >
1313
+ <svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1314
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
1315
+ </svg>
1316
+ </button>
1317
+ )}
1318
+ </div>
1319
+ </div>
1320
+
1321
+ <div css={styles.right}>
1322
+ {hasSelection && (
1323
+ <span css={styles.selectionCount}>
1324
+ {selectedItems.size} selected
1325
+ <button css={styles.clearBtn} onClick={clearSelection}>
1326
+ Clear
1327
+ </button>
1328
+ </span>
1329
+ )}
1330
+
1331
+ <button
1332
+ css={styles.btn}
1333
+ onClick={handleScan}
1334
+ disabled={scanning}
1335
+ >
1336
+ <ScanIcon spinning={scanning} />
1337
+ Scan
1338
+ </button>
1339
+
1340
+ <div css={styles.viewToggle}>
1341
+ <button
1342
+ css={[styles.viewBtn, viewMode === 'grid' && styles.viewBtnActive]}
1343
+ onClick={() => setViewMode('grid')}
1344
+ aria-label="Grid view"
1345
+ >
1346
+ <GridIcon />
1347
+ </button>
1348
+ <button
1349
+ css={[styles.viewBtn, viewMode === 'list' && styles.viewBtnActive]}
1350
+ onClick={() => setViewMode('list')}
1351
+ aria-label="List view"
1352
+ >
1353
+ <ListIcon />
1354
+ </button>
1355
+ </div>
1356
+ </div>
1357
+ </div>
1358
+ </>
1359
+ )
1360
+ }
1361
+
1362
+ function UploadIcon() {
1363
+ return (
1364
+ <svg css={styles.icon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
1365
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
1366
+ </svg>
1367
+ )
1368
+ }
1369
+
1370
+ function ScanIcon({ spinning }: { spinning?: boolean }) {
1371
+ return (
1372
+ <svg css={[styles.icon, spinning && styles.iconSpin]} fill="none" stroke="currentColor" viewBox="0 0 24 24">
1373
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
1374
+ </svg>
1375
+ )
1376
+ }
1377
+
1378
+ function TrashIcon() {
1379
+ return (
1380
+ <svg css={styles.icon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
1381
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
1382
+ </svg>
1383
+ )
1384
+ }
1385
+
1386
+ function FolderPlusIcon() {
1387
+ return (
1388
+ <svg css={styles.icon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
1389
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 13h6m-3-3v6m-9 1V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
1390
+ </svg>
1391
+ )
1392
+ }
1393
+
1394
+ function RenameIcon() {
1395
+ return (
1396
+ <svg css={styles.icon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
1397
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
1398
+ </svg>
1399
+ )
1400
+ }
1401
+
1402
+ function MoveIcon() {
1403
+ return (
1404
+ <svg css={styles.icon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
1405
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
1406
+ </svg>
1407
+ )
1408
+ }
1409
+
1410
+ function CloudIcon() {
1411
+ return (
1412
+ <svg css={styles.icon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
1413
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
1414
+ </svg>
1415
+ )
1416
+ }
1417
+
1418
+ function CloudDownloadIcon() {
1419
+ return (
1420
+ <svg css={styles.icon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
1421
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
1422
+ </svg>
1423
+ )
1424
+ }
1425
+
1426
+ function GridIcon() {
1427
+ return (
1428
+ <svg css={styles.icon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
1429
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
1430
+ </svg>
1431
+ )
1432
+ }
1433
+
1434
+ function ListIcon() {
1435
+ return (
1436
+ <svg css={styles.icon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
1437
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
1438
+ </svg>
1439
+ )
1440
+ }
1441
+
1442
+ function ImageStackIcon() {
1443
+ return (
1444
+ <svg css={styles.icon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
1445
+ <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" />
1446
+ </svg>
1447
+ )
1448
+ }