@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.
- package/app/api/studio/[...path]/route.ts +1 -0
- package/app/layout.tsx +23 -0
- package/app/page.tsx +90 -0
- package/bin/studio.mjs +110 -0
- package/dist/handlers/index.js +77 -55
- package/dist/handlers/index.js.map +1 -1
- package/dist/handlers/index.mjs +128 -106
- package/dist/handlers/index.mjs.map +1 -1
- package/dist/index.d.mts +14 -10
- package/dist/index.d.ts +14 -10
- package/dist/index.js +2 -177
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +4 -179
- package/dist/index.mjs.map +1 -1
- package/next.config.mjs +22 -0
- package/package.json +18 -10
- package/src/components/AddNewModal.tsx +402 -0
- package/src/components/ErrorModal.tsx +89 -0
- package/src/components/R2SetupModal.tsx +400 -0
- package/src/components/StudioBreadcrumb.tsx +115 -0
- package/src/components/StudioButton.tsx +200 -0
- package/src/components/StudioContext.tsx +219 -0
- package/src/components/StudioDetailView.tsx +714 -0
- package/src/components/StudioFileGrid.tsx +704 -0
- package/src/components/StudioFileList.tsx +743 -0
- package/src/components/StudioFolderPicker.tsx +342 -0
- package/src/components/StudioModal.tsx +473 -0
- package/src/components/StudioPreview.tsx +399 -0
- package/src/components/StudioSettings.tsx +536 -0
- package/src/components/StudioToolbar.tsx +1448 -0
- package/src/components/StudioUI.tsx +731 -0
- package/src/components/styles/common.ts +236 -0
- package/src/components/tokens.ts +78 -0
- package/src/components/useStudioActions.tsx +497 -0
- package/src/config/index.ts +7 -0
- package/src/config/workspace.ts +52 -0
- package/src/handlers/favicon.ts +152 -0
- package/src/handlers/files.ts +784 -0
- package/src/handlers/images.ts +949 -0
- package/src/handlers/import.ts +190 -0
- package/src/handlers/index.ts +168 -0
- package/src/handlers/list.ts +627 -0
- package/src/handlers/scan.ts +311 -0
- package/src/handlers/utils/cdn.ts +234 -0
- package/src/handlers/utils/files.ts +64 -0
- package/src/handlers/utils/index.ts +4 -0
- package/src/handlers/utils/meta.ts +102 -0
- package/src/handlers/utils/thumbnails.ts +98 -0
- package/src/hooks/useFileList.ts +143 -0
- package/src/index.tsx +36 -0
- package/src/lib/api.ts +176 -0
- package/src/types.ts +119 -0
- package/dist/StudioUI-GJK45R3T.js +0 -6500
- package/dist/StudioUI-GJK45R3T.js.map +0 -1
- package/dist/StudioUI-QZ54STXE.mjs +0 -6500
- package/dist/StudioUI-QZ54STXE.mjs.map +0 -1
- package/dist/chunk-N6JYTJCB.js +0 -68
- package/dist/chunk-N6JYTJCB.js.map +0 -1
- package/dist/chunk-RHI3UROE.mjs +0 -68
- 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
|
+
}
|