@gallop.software/studio 1.5.10 → 2.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.
Files changed (60) hide show
  1. package/app/api/studio/[...path]/route.ts +1 -0
  2. package/app/layout.tsx +23 -0
  3. package/app/page.tsx +90 -0
  4. package/bin/studio.mjs +110 -0
  5. package/dist/handlers/index.js +77 -55
  6. package/dist/handlers/index.js.map +1 -1
  7. package/dist/handlers/index.mjs +128 -106
  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,731 @@
1
+ /** @jsxImportSource @emotion/react */
2
+ 'use client'
3
+
4
+ import { useEffect, useCallback, useState } from 'react'
5
+ import { css } from '@emotion/react'
6
+ import { StudioContext } from './StudioContext'
7
+ import { StudioToolbar } from './StudioToolbar'
8
+ import { StudioFileGrid } from './StudioFileGrid'
9
+ import { StudioFileList } from './StudioFileList'
10
+ import { StudioDetailView } from './StudioDetailView'
11
+ import { StudioSettings } from './StudioSettings'
12
+ import { ErrorModal } from './ErrorModal'
13
+ import { ConfirmModal, ProgressModal } from './StudioModal'
14
+ import { StudioFolderPicker } from './StudioFolderPicker'
15
+ import { useStudioActions } from './useStudioActions'
16
+ import { colors, fontSize, baseReset } from './tokens'
17
+ import type { FileItem, LeanMeta } from '../types'
18
+
19
+ interface StudioUIProps {
20
+ onClose?: () => void
21
+ isVisible?: boolean
22
+ standaloneMode?: boolean
23
+ workspacePath?: string
24
+ }
25
+
26
+ // Standard button height for consistency
27
+ const btnHeight = '36px'
28
+
29
+ const styles = {
30
+ container: css`
31
+ ${baseReset}
32
+ display: flex;
33
+ flex-direction: column;
34
+ height: 100%;
35
+ background: ${colors.background};
36
+ `,
37
+ header: css`
38
+ display: flex;
39
+ align-items: center;
40
+ justify-content: space-between;
41
+ padding: 12px 24px;
42
+ background: ${colors.surface};
43
+ border-bottom: 1px solid ${colors.border};
44
+ position: relative;
45
+ `,
46
+ title: css`
47
+ font-size: ${fontSize.lg};
48
+ font-weight: 600;
49
+ color: ${colors.text};
50
+ margin: 0;
51
+ letter-spacing: -0.02em;
52
+ flex-shrink: 0;
53
+ `,
54
+ headerLeft: css`
55
+ display: flex;
56
+ align-items: center;
57
+ gap: 12px;
58
+ flex: 1;
59
+ min-width: 0;
60
+ `,
61
+ headerCenter: css`
62
+ position: absolute;
63
+ left: 50%;
64
+ transform: translateX(-50%);
65
+ display: flex;
66
+ align-items: center;
67
+ max-width: 50%;
68
+ `,
69
+ breadcrumbs: css`
70
+ display: flex;
71
+ align-items: center;
72
+ gap: 6px;
73
+ font-size: ${fontSize.base};
74
+ color: ${colors.textSecondary};
75
+ overflow: hidden;
76
+ `,
77
+ breadcrumbSeparator: css`
78
+ color: ${colors.border};
79
+ flex-shrink: 0;
80
+ `,
81
+ breadcrumbItem: css`
82
+ color: ${colors.textSecondary};
83
+ text-decoration: none;
84
+ cursor: pointer;
85
+ transition: color 0.15s ease;
86
+ white-space: nowrap;
87
+
88
+ &:hover {
89
+ color: ${colors.primary};
90
+ }
91
+ `,
92
+ breadcrumbCurrent: css`
93
+ color: ${colors.text};
94
+ font-weight: 500;
95
+ white-space: nowrap;
96
+ overflow: hidden;
97
+ text-overflow: ellipsis;
98
+ `,
99
+ headerActions: css`
100
+ display: flex;
101
+ align-items: center;
102
+ gap: 8px;
103
+ `,
104
+ headerBtn: css`
105
+ height: ${btnHeight};
106
+ padding: 0 12px;
107
+ background: ${colors.surface};
108
+ border: 1px solid ${colors.border};
109
+ border-radius: 6px;
110
+ cursor: pointer;
111
+ transition: all 0.15s ease;
112
+ display: flex;
113
+ align-items: center;
114
+ justify-content: center;
115
+
116
+ &:hover {
117
+ background-color: ${colors.surfaceHover};
118
+ border-color: ${colors.borderHover};
119
+ }
120
+ `,
121
+ headerIcon: css`
122
+ width: 16px;
123
+ height: 16px;
124
+ color: ${colors.textSecondary};
125
+ `,
126
+ workspacePath: css`
127
+ font-size: ${fontSize.sm};
128
+ color: ${colors.textMuted};
129
+ padding: 4px 10px;
130
+ background: ${colors.background};
131
+ border-radius: 4px;
132
+ font-family: monospace;
133
+ max-width: 200px;
134
+ overflow: hidden;
135
+ text-overflow: ellipsis;
136
+ white-space: nowrap;
137
+ `,
138
+ content: css`
139
+ flex: 1;
140
+ display: flex;
141
+ overflow: hidden;
142
+ `,
143
+ fileBrowser: css`
144
+ flex: 1;
145
+ min-width: 0;
146
+ overflow: auto;
147
+ padding: 20px 24px;
148
+ display: flex;
149
+ flex-direction: column;
150
+ `,
151
+ dropOverlay: css`
152
+ position: absolute;
153
+ top: 0;
154
+ left: 0;
155
+ right: 0;
156
+ bottom: 0;
157
+ background: rgba(99, 91, 255, 0.1);
158
+ border: 3px dashed ${colors.primary};
159
+ border-radius: 8px;
160
+ display: flex;
161
+ align-items: center;
162
+ justify-content: center;
163
+ z-index: 50;
164
+ pointer-events: none;
165
+ `,
166
+ dropMessage: css`
167
+ display: flex;
168
+ flex-direction: column;
169
+ align-items: center;
170
+ gap: 12px;
171
+ color: ${colors.primary};
172
+ font-size: ${fontSize.lg};
173
+ font-weight: 600;
174
+ `,
175
+ dropIcon: css`
176
+ width: 48px;
177
+ height: 48px;
178
+ `,
179
+ }
180
+
181
+ /**
182
+ * Main Studio UI - contains all panels and manages internal state
183
+ * Rendered inside the modal via lazy loading
184
+ */
185
+ export function StudioUI({ onClose, isVisible = true, standaloneMode = false, workspacePath }: StudioUIProps) {
186
+ const [currentPath, setCurrentPathInternal] = useState('public')
187
+ const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set())
188
+ const [lastSelectedPath, setLastSelectedPath] = useState<string | null>(null)
189
+ const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
190
+ const [focusedItem, setFocusedItem] = useState<FileItem | null>(null)
191
+ const [meta, setMeta] = useState<LeanMeta | null>(null)
192
+ const [isLoading, setIsLoading] = useState(false)
193
+ const [refreshKey, setRefreshKey] = useState(0)
194
+ const [scanRequested, setScanRequested] = useState(false)
195
+ const [searchQuery, setSearchQuery] = useState('')
196
+ const [error, setError] = useState<{ title: string; message: string } | null>(null)
197
+ const [fileItems, setFileItems] = useState<FileItem[]>([])
198
+ const [isDragging, setIsDragging] = useState(false)
199
+
200
+ const triggerRefresh = useCallback(() => {
201
+ setRefreshKey((k) => k + 1)
202
+ }, [])
203
+
204
+ const triggerScan = useCallback(() => {
205
+ setScanRequested(true)
206
+ }, [])
207
+
208
+ const clearScanRequest = useCallback(() => {
209
+ setScanRequested(false)
210
+ }, [])
211
+
212
+ const showError = useCallback((title: string, message: string) => {
213
+ setError({ title, message })
214
+ }, [])
215
+
216
+ const clearError = useCallback(() => {
217
+ setError(null)
218
+ }, [])
219
+
220
+ const handleDragOver = useCallback((e: React.DragEvent) => {
221
+ e.preventDefault()
222
+ e.stopPropagation()
223
+ setIsDragging(true)
224
+ }, [])
225
+
226
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
227
+ e.preventDefault()
228
+ e.stopPropagation()
229
+ setIsDragging(false)
230
+ }, [])
231
+
232
+ const handleDrop = useCallback(async (e: React.DragEvent) => {
233
+ e.preventDefault()
234
+ e.stopPropagation()
235
+ setIsDragging(false)
236
+
237
+ const files = Array.from(e.dataTransfer.files)
238
+ if (files.length === 0) return
239
+
240
+ // Don't allow drops in the images folder
241
+ if (currentPath === 'public/images' || currentPath.startsWith('public/images/')) {
242
+ return
243
+ }
244
+
245
+ for (const file of files) {
246
+ const formData = new FormData()
247
+ formData.append('file', file)
248
+ formData.append('path', currentPath)
249
+
250
+ try {
251
+ await fetch('/api/studio/upload', {
252
+ method: 'POST',
253
+ body: formData,
254
+ })
255
+ } catch (error) {
256
+ console.error('Upload error:', error)
257
+ }
258
+ }
259
+ triggerRefresh()
260
+ }, [currentPath, triggerRefresh])
261
+
262
+ const navigateUp = useCallback(() => {
263
+ if (currentPath === 'public') return
264
+ const parts = currentPath.split('/')
265
+ parts.pop()
266
+ setCurrentPathInternal(parts.join('/') || 'public')
267
+ setSelectedItems(new Set())
268
+ }, [currentPath])
269
+
270
+ const setCurrentPath = useCallback((path: string) => {
271
+ setCurrentPathInternal(path)
272
+ setSelectedItems(new Set())
273
+ setFocusedItem(null)
274
+ }, [])
275
+
276
+ const toggleSelection = useCallback((path: string) => {
277
+ setSelectedItems((prev) => {
278
+ const next = new Set(prev)
279
+ if (next.has(path)) {
280
+ next.delete(path)
281
+ } else {
282
+ next.add(path)
283
+ }
284
+ return next
285
+ })
286
+ setLastSelectedPath(path)
287
+ }, [])
288
+
289
+ const selectRange = useCallback((fromPath: string, toPath: string, allItems: FileItem[]) => {
290
+ const fromIndex = allItems.findIndex(item => item.path === fromPath)
291
+ const toIndex = allItems.findIndex(item => item.path === toPath)
292
+
293
+ if (fromIndex === -1 || toIndex === -1) return
294
+
295
+ const start = Math.min(fromIndex, toIndex)
296
+ const end = Math.max(fromIndex, toIndex)
297
+
298
+ setSelectedItems((prev) => {
299
+ const next = new Set(prev)
300
+ for (let i = start; i <= end; i++) {
301
+ next.add(allItems[i].path)
302
+ }
303
+ return next
304
+ })
305
+ setLastSelectedPath(toPath)
306
+ }, [])
307
+
308
+ const selectAll = useCallback((items: FileItem[]) => {
309
+ setSelectedItems(new Set(items.map((item) => item.path)))
310
+ }, [])
311
+
312
+ const clearSelection = useCallback(() => {
313
+ setSelectedItems(new Set())
314
+ }, [])
315
+
316
+ // Shared action handlers
317
+ const setFocusedItemCallback = useCallback((item: FileItem | null) => {
318
+ setFocusedItem(item)
319
+ }, [])
320
+
321
+ const actions = useStudioActions({
322
+ triggerRefresh,
323
+ clearSelection,
324
+ setFocusedItem: setFocusedItemCallback,
325
+ showError,
326
+ })
327
+
328
+ const handleKeyDown = useCallback(
329
+ (e: KeyboardEvent) => {
330
+ if (e.key === 'Escape') {
331
+ // Don't close if user is in an input field (e.g., search)
332
+ const target = e.target as HTMLElement
333
+ if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
334
+ return
335
+ }
336
+
337
+ if (focusedItem) {
338
+ setFocusedItem(null)
339
+ } else if (onClose) {
340
+ onClose()
341
+ }
342
+ }
343
+ },
344
+ [onClose, focusedItem, standaloneMode]
345
+ )
346
+
347
+ useEffect(() => {
348
+ if (isVisible) {
349
+ document.addEventListener('keydown', handleKeyDown)
350
+ document.body.style.overflow = 'hidden'
351
+ }
352
+ return () => {
353
+ document.removeEventListener('keydown', handleKeyDown)
354
+ document.body.style.overflow = ''
355
+ }
356
+ }, [handleKeyDown, isVisible])
357
+
358
+ const contextValue = {
359
+ isOpen: true,
360
+ openStudio: () => {},
361
+ closeStudio: onClose || (() => {}),
362
+ toggleStudio: onClose || (() => {}),
363
+ currentPath,
364
+ setCurrentPath,
365
+ navigateUp,
366
+ selectedItems,
367
+ toggleSelection,
368
+ selectRange,
369
+ selectAll,
370
+ clearSelection,
371
+ lastSelectedPath,
372
+ viewMode,
373
+ setViewMode,
374
+ focusedItem,
375
+ setFocusedItem,
376
+ meta,
377
+ setMeta,
378
+ isLoading,
379
+ setIsLoading,
380
+ refreshKey,
381
+ triggerRefresh,
382
+ scanRequested,
383
+ triggerScan,
384
+ clearScanRequest,
385
+ searchQuery,
386
+ setSearchQuery,
387
+ error,
388
+ showError,
389
+ clearError,
390
+ fileItems,
391
+ setFileItems,
392
+ // Shared action state and handlers
393
+ actionState: actions.actionState,
394
+ requestDelete: actions.requestDelete,
395
+ requestMove: actions.requestMove,
396
+ requestSync: actions.requestSync,
397
+ requestProcess: actions.requestProcess,
398
+ setProcessMode: actions.setProcessMode,
399
+ confirmDelete: actions.confirmDelete,
400
+ confirmMove: actions.confirmMove,
401
+ confirmSync: actions.confirmSync,
402
+ confirmProcess: actions.confirmProcess,
403
+ cancelAction: actions.cancelAction,
404
+ closeProgress: actions.closeProgress,
405
+ stopProcessing: actions.stopProcessing,
406
+ abortController: actions.abortController,
407
+ deleteOrphans: actions.deleteOrphans,
408
+ }
409
+
410
+ return (
411
+ <StudioContext.Provider value={contextValue}>
412
+ <div css={styles.container}>
413
+ <div css={styles.header}>
414
+ <div css={styles.headerLeft}>
415
+ <h1 css={styles.title}>Studio</h1>
416
+ </div>
417
+ <div css={styles.headerCenter}>
418
+ <Breadcrumbs currentPath={currentPath} onNavigate={setCurrentPath} />
419
+ </div>
420
+ <div css={styles.headerActions}>
421
+ {standaloneMode && workspacePath && (
422
+ <span css={styles.workspacePath} title={workspacePath}>
423
+ {workspacePath.length > 30 ? '...' + workspacePath.slice(-27) : workspacePath}
424
+ </span>
425
+ )}
426
+ <StudioSettings />
427
+ {!standaloneMode && onClose && (
428
+ <button
429
+ css={styles.headerBtn}
430
+ onClick={onClose}
431
+ aria-label="Close Studio"
432
+ >
433
+ <CloseIcon />
434
+ </button>
435
+ )}
436
+ </div>
437
+ </div>
438
+
439
+ <StudioToolbar />
440
+
441
+ <div
442
+ css={styles.content}
443
+ onDragOver={handleDragOver}
444
+ onDragLeave={handleDragLeave}
445
+ onDrop={handleDrop}
446
+ >
447
+ {isDragging && (
448
+ <div css={styles.dropOverlay}>
449
+ <div css={styles.dropMessage}>
450
+ <svg css={styles.dropIcon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
451
+ <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" />
452
+ </svg>
453
+ <span>Drop files to upload</span>
454
+ </div>
455
+ </div>
456
+ )}
457
+ <div css={styles.fileBrowser}>
458
+ {viewMode === 'grid' ? <StudioFileGrid /> : <StudioFileList />}
459
+ </div>
460
+ </div>
461
+
462
+ {/* Detail view as modal overlay */}
463
+ {focusedItem && <StudioDetailView />}
464
+
465
+ {/* Error modal */}
466
+ <ErrorModal />
467
+
468
+ {/* Shared action modals */}
469
+ {actions.actionState.showDeleteConfirm && (
470
+ <ConfirmModal
471
+ title="Delete Files"
472
+ message={`Are you sure you want to delete ${actions.actionState.actionPaths.length} item${actions.actionState.actionPaths.length !== 1 ? 's' : ''}? This action cannot be undone.`}
473
+ confirmLabel="Delete"
474
+ variant="danger"
475
+ onConfirm={actions.confirmDelete}
476
+ onCancel={actions.cancelAction}
477
+ />
478
+ )}
479
+
480
+ {actions.actionState.showSyncConfirm && (
481
+ <ConfirmModal
482
+ title="Push to CDN"
483
+ message={`Push ${actions.actionState.syncImageCount} image${actions.actionState.syncImageCount !== 1 ? 's' : ''} to Cloudflare R2?${actions.actionState.syncHasRemote ? ' Remote images will be downloaded first.' : ''}${actions.actionState.syncHasLocal ? ' After pushing, local files will be deleted.' : ''}`}
484
+ confirmLabel="Push"
485
+ onConfirm={actions.confirmSync}
486
+ onCancel={actions.cancelAction}
487
+ />
488
+ )}
489
+
490
+ {actions.actionState.showProcessConfirm && (
491
+ <ProcessConfirmModal
492
+ imageCount={actions.actionState.actionPaths.length}
493
+ mode={actions.actionState.processMode}
494
+ onModeChange={actions.setProcessMode}
495
+ onConfirm={actions.confirmProcess}
496
+ onCancel={actions.cancelAction}
497
+ />
498
+ )}
499
+
500
+ {actions.actionState.showMoveModal && (
501
+ <StudioFolderPicker
502
+ selectedItems={new Set(actions.actionState.actionPaths)}
503
+ currentPath={currentPath}
504
+ onMove={(destination) => actions.confirmMove(destination)}
505
+ onCancel={actions.cancelAction}
506
+ />
507
+ )}
508
+
509
+ {actions.actionState.showProgress && (
510
+ <ProgressModal
511
+ title={actions.actionState.progressTitle}
512
+ progress={actions.actionState.progressState}
513
+ onStop={actions.stopProcessing}
514
+ onDeleteOrphans={actions.deleteOrphans}
515
+ onClose={actions.closeProgress}
516
+ />
517
+ )}
518
+ </div>
519
+ </StudioContext.Provider>
520
+ )
521
+ }
522
+
523
+ interface ProcessConfirmModalProps {
524
+ imageCount: number
525
+ mode: 'generate' | 'remove'
526
+ onModeChange: (mode: 'generate' | 'remove') => void
527
+ onConfirm: () => void
528
+ onCancel: () => void
529
+ }
530
+
531
+ function ProcessConfirmModal({ imageCount, mode, onModeChange, onConfirm, onCancel }: ProcessConfirmModalProps) {
532
+ const processModalStyles = {
533
+ overlay: css`
534
+ position: fixed;
535
+ inset: 0;
536
+ background: rgba(0, 0, 0, 0.5);
537
+ display: flex;
538
+ align-items: center;
539
+ justify-content: center;
540
+ z-index: 10000;
541
+ `,
542
+ container: css`
543
+ background: ${colors.surface};
544
+ border-radius: 12px;
545
+ padding: 24px;
546
+ max-width: 420px;
547
+ width: 90%;
548
+ box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
549
+ `,
550
+ title: css`
551
+ font-size: ${fontSize.lg};
552
+ font-weight: 600;
553
+ color: ${colors.text};
554
+ margin: 0 0 16px;
555
+ `,
556
+ modeToggle: css`
557
+ display: flex;
558
+ gap: 8px;
559
+ margin-bottom: 16px;
560
+ `,
561
+ modeBtn: css`
562
+ flex: 1;
563
+ padding: 10px 16px;
564
+ border: 2px solid ${colors.border};
565
+ border-radius: 8px;
566
+ background: ${colors.background};
567
+ color: ${colors.textSecondary};
568
+ font-size: ${fontSize.base};
569
+ font-weight: 500;
570
+ cursor: pointer;
571
+ transition: all 0.15s ease;
572
+
573
+ &:hover {
574
+ border-color: ${colors.borderHover};
575
+ }
576
+ `,
577
+ modeBtnActive: css`
578
+ border-color: ${colors.primary};
579
+ background: rgba(99, 91, 255, 0.1);
580
+ color: ${colors.primary};
581
+ `,
582
+ modeBtnDanger: css`
583
+ border-color: ${colors.danger};
584
+ background: rgba(239, 68, 68, 0.1);
585
+ color: ${colors.danger};
586
+ `,
587
+ message: css`
588
+ font-size: ${fontSize.base};
589
+ color: ${colors.textSecondary};
590
+ margin: 0 0 20px;
591
+ line-height: 1.5;
592
+ `,
593
+ actions: css`
594
+ display: flex;
595
+ gap: 12px;
596
+ justify-content: flex-end;
597
+ `,
598
+ cancelBtn: css`
599
+ padding: 10px 20px;
600
+ border: 1px solid ${colors.border};
601
+ border-radius: 8px;
602
+ background: ${colors.background};
603
+ color: ${colors.text};
604
+ font-size: ${fontSize.base};
605
+ font-weight: 500;
606
+ cursor: pointer;
607
+ transition: all 0.15s ease;
608
+
609
+ &:hover {
610
+ background: ${colors.surfaceHover};
611
+ border-color: ${colors.borderHover};
612
+ }
613
+ `,
614
+ confirmBtn: css`
615
+ padding: 10px 20px;
616
+ border: none;
617
+ border-radius: 8px;
618
+ background: ${colors.primary};
619
+ color: white;
620
+ font-size: ${fontSize.base};
621
+ font-weight: 500;
622
+ cursor: pointer;
623
+ transition: all 0.15s ease;
624
+
625
+ &:hover {
626
+ background: ${colors.primaryHover};
627
+ }
628
+ `,
629
+ confirmBtnDanger: css`
630
+ background: ${colors.danger};
631
+
632
+ &:hover {
633
+ background: #dc2626;
634
+ }
635
+ `,
636
+ }
637
+
638
+ const isRemove = mode === 'remove'
639
+ const title = 'Process Images'
640
+ const message = isRemove
641
+ ? `Remove generated thumbnails for ${imageCount} image${imageCount !== 1 ? 's' : ''}? Original images will be kept.`
642
+ : `Generate thumbnails for ${imageCount} image${imageCount !== 1 ? 's' : ''}?`
643
+ const confirmLabel = isRemove ? 'Remove' : 'Process'
644
+
645
+ return (
646
+ <div css={processModalStyles.overlay} onClick={onCancel}>
647
+ <div css={processModalStyles.container} onClick={e => e.stopPropagation()}>
648
+ <h2 css={processModalStyles.title}>{title}</h2>
649
+
650
+ <div css={processModalStyles.modeToggle}>
651
+ <button
652
+ css={[processModalStyles.modeBtn, mode === 'generate' && processModalStyles.modeBtnActive]}
653
+ onClick={() => onModeChange('generate')}
654
+ >
655
+ Generate Thumbnails
656
+ </button>
657
+ <button
658
+ css={[processModalStyles.modeBtn, mode === 'remove' && processModalStyles.modeBtnDanger]}
659
+ onClick={() => onModeChange('remove')}
660
+ >
661
+ Remove Thumbnails
662
+ </button>
663
+ </div>
664
+
665
+ <p css={processModalStyles.message}>{message}</p>
666
+
667
+ <div css={processModalStyles.actions}>
668
+ <button css={processModalStyles.cancelBtn} onClick={onCancel}>
669
+ Cancel
670
+ </button>
671
+ <button
672
+ css={[processModalStyles.confirmBtn, isRemove && processModalStyles.confirmBtnDanger]}
673
+ onClick={onConfirm}
674
+ >
675
+ {confirmLabel}
676
+ </button>
677
+ </div>
678
+ </div>
679
+ </div>
680
+ )
681
+ }
682
+
683
+ function Breadcrumbs({ currentPath, onNavigate }: { currentPath: string; onNavigate: (path: string) => void }) {
684
+ const parts = currentPath.split('/').filter(Boolean)
685
+
686
+ // Build paths for each breadcrumb
687
+ const breadcrumbs = parts.map((part, index) => ({
688
+ name: part,
689
+ path: parts.slice(0, index + 1).join('/')
690
+ }))
691
+
692
+ return (
693
+ <div css={styles.breadcrumbs}>
694
+ {breadcrumbs.map((crumb, index) => (
695
+ <span key={crumb.path} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
696
+ {index > 0 && <span css={styles.breadcrumbSeparator}>/</span>}
697
+ {index === breadcrumbs.length - 1 ? (
698
+ <span css={styles.breadcrumbCurrent}>{crumb.name}</span>
699
+ ) : (
700
+ <span
701
+ css={styles.breadcrumbItem}
702
+ onClick={() => onNavigate(crumb.path)}
703
+ >
704
+ {crumb.name}
705
+ </span>
706
+ )}
707
+ </span>
708
+ ))}
709
+ </div>
710
+ )
711
+ }
712
+
713
+ function CloseIcon() {
714
+ return (
715
+ <svg
716
+ css={styles.headerIcon}
717
+ xmlns="http://www.w3.org/2000/svg"
718
+ viewBox="0 0 24 24"
719
+ fill="none"
720
+ stroke="currentColor"
721
+ strokeWidth={2}
722
+ strokeLinecap="round"
723
+ strokeLinejoin="round"
724
+ >
725
+ <line x1="18" y1="6" x2="6" y2="18" />
726
+ <line x1="6" y1="6" x2="18" y2="18" />
727
+ </svg>
728
+ )
729
+ }
730
+
731
+ export default StudioUI