@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.
- package/app/api/studio/[...path]/route.ts +1 -0
- package/app/layout.tsx +20 -0
- package/app/page.tsx +82 -0
- package/bin/studio.mjs +110 -0
- package/dist/handlers/index.js +84 -63
- package/dist/handlers/index.js.map +1 -1
- package/dist/handlers/index.mjs +135 -114
- 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,497 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback, useRef } from 'react'
|
|
4
|
+
import type { ActionState, ProgressState } from './StudioContext'
|
|
5
|
+
import type { FileItem } from '../types'
|
|
6
|
+
|
|
7
|
+
const defaultActionState: ActionState = {
|
|
8
|
+
showProgress: false,
|
|
9
|
+
progressTitle: '',
|
|
10
|
+
progressState: { current: 0, total: 0, percent: 0, status: 'processing' },
|
|
11
|
+
showDeleteConfirm: false,
|
|
12
|
+
showMoveModal: false,
|
|
13
|
+
showSyncConfirm: false,
|
|
14
|
+
showProcessConfirm: false,
|
|
15
|
+
actionPaths: [],
|
|
16
|
+
syncImageCount: 0,
|
|
17
|
+
syncHasRemote: false,
|
|
18
|
+
syncHasLocal: false,
|
|
19
|
+
processMode: 'generate',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface UseStudioActionsProps {
|
|
23
|
+
triggerRefresh: () => void
|
|
24
|
+
clearSelection: () => void
|
|
25
|
+
setFocusedItem: (item: FileItem | null) => void
|
|
26
|
+
showError: (title: string, message: string) => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function useStudioActions({
|
|
30
|
+
triggerRefresh,
|
|
31
|
+
clearSelection,
|
|
32
|
+
setFocusedItem,
|
|
33
|
+
showError,
|
|
34
|
+
}: UseStudioActionsProps) {
|
|
35
|
+
const [actionState, setActionState] = useState<ActionState>(defaultActionState)
|
|
36
|
+
const abortControllerRef = useRef<AbortController | null>(null)
|
|
37
|
+
|
|
38
|
+
// Helper to update progress state
|
|
39
|
+
const setProgressState = useCallback((update: Partial<ProgressState> | ((prev: ProgressState) => ProgressState)) => {
|
|
40
|
+
setActionState(prev => ({
|
|
41
|
+
...prev,
|
|
42
|
+
progressState: typeof update === 'function'
|
|
43
|
+
? update(prev.progressState)
|
|
44
|
+
: { ...prev.progressState, ...update }
|
|
45
|
+
}))
|
|
46
|
+
}, [])
|
|
47
|
+
|
|
48
|
+
// Request handlers (show confirmation modals)
|
|
49
|
+
const requestDelete = useCallback((paths: string[]) => {
|
|
50
|
+
setActionState(prev => ({
|
|
51
|
+
...prev,
|
|
52
|
+
actionPaths: paths,
|
|
53
|
+
showDeleteConfirm: true,
|
|
54
|
+
}))
|
|
55
|
+
}, [])
|
|
56
|
+
|
|
57
|
+
const requestMove = useCallback((paths: string[]) => {
|
|
58
|
+
setActionState(prev => ({
|
|
59
|
+
...prev,
|
|
60
|
+
actionPaths: paths,
|
|
61
|
+
showMoveModal: true,
|
|
62
|
+
}))
|
|
63
|
+
}, [])
|
|
64
|
+
|
|
65
|
+
const requestSync = useCallback((paths: string[], fileItems: FileItem[]) => {
|
|
66
|
+
// Calculate sync info
|
|
67
|
+
const imageKeys = paths.map(p => '/' + p.replace(/^public\//, ''))
|
|
68
|
+
let hasRemote = false
|
|
69
|
+
let hasLocal = false
|
|
70
|
+
|
|
71
|
+
for (const path of paths) {
|
|
72
|
+
const item = fileItems.find(f => f.path === path)
|
|
73
|
+
if (item) {
|
|
74
|
+
if (item.isRemote) {
|
|
75
|
+
hasRemote = true
|
|
76
|
+
} else if (!item.cdnPushed) {
|
|
77
|
+
hasLocal = true
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
setActionState(prev => ({
|
|
83
|
+
...prev,
|
|
84
|
+
actionPaths: paths,
|
|
85
|
+
syncImageCount: imageKeys.length,
|
|
86
|
+
syncHasRemote: hasRemote,
|
|
87
|
+
syncHasLocal: hasLocal,
|
|
88
|
+
showSyncConfirm: true,
|
|
89
|
+
}))
|
|
90
|
+
}, [])
|
|
91
|
+
|
|
92
|
+
const requestProcess = useCallback((paths: string[]) => {
|
|
93
|
+
setActionState(prev => ({
|
|
94
|
+
...prev,
|
|
95
|
+
actionPaths: paths,
|
|
96
|
+
showProcessConfirm: true,
|
|
97
|
+
processMode: 'generate', // Reset to default when opening modal
|
|
98
|
+
}))
|
|
99
|
+
}, [])
|
|
100
|
+
|
|
101
|
+
const setProcessMode = useCallback((mode: 'generate' | 'remove') => {
|
|
102
|
+
setActionState(prev => ({
|
|
103
|
+
...prev,
|
|
104
|
+
processMode: mode,
|
|
105
|
+
}))
|
|
106
|
+
}, [])
|
|
107
|
+
|
|
108
|
+
// Cancel action
|
|
109
|
+
const cancelAction = useCallback(() => {
|
|
110
|
+
setActionState(prev => ({
|
|
111
|
+
...prev,
|
|
112
|
+
showDeleteConfirm: false,
|
|
113
|
+
showMoveModal: false,
|
|
114
|
+
showSyncConfirm: false,
|
|
115
|
+
showProcessConfirm: false,
|
|
116
|
+
}))
|
|
117
|
+
}, [])
|
|
118
|
+
|
|
119
|
+
// Close progress modal
|
|
120
|
+
const closeProgress = useCallback(() => {
|
|
121
|
+
setActionState(defaultActionState)
|
|
122
|
+
}, [])
|
|
123
|
+
|
|
124
|
+
// Stop processing
|
|
125
|
+
const stopProcessing = useCallback(() => {
|
|
126
|
+
if (abortControllerRef.current) {
|
|
127
|
+
abortControllerRef.current.abort()
|
|
128
|
+
}
|
|
129
|
+
}, [])
|
|
130
|
+
|
|
131
|
+
// Confirm delete
|
|
132
|
+
const confirmDelete = useCallback(async () => {
|
|
133
|
+
const paths = actionState.actionPaths
|
|
134
|
+
setActionState(prev => ({ ...prev, showDeleteConfirm: false }))
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const response = await fetch('/api/studio/delete', {
|
|
138
|
+
method: 'POST',
|
|
139
|
+
headers: { 'Content-Type': 'application/json' },
|
|
140
|
+
body: JSON.stringify({ paths }),
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
if (response.ok) {
|
|
144
|
+
clearSelection()
|
|
145
|
+
setFocusedItem(null)
|
|
146
|
+
triggerRefresh()
|
|
147
|
+
} else {
|
|
148
|
+
const error = await response.json()
|
|
149
|
+
showError('Delete Failed', error.error || 'Unknown error')
|
|
150
|
+
}
|
|
151
|
+
} catch (error) {
|
|
152
|
+
console.error('Delete error:', error)
|
|
153
|
+
showError('Delete Failed', 'Delete failed. Check console for details.')
|
|
154
|
+
}
|
|
155
|
+
}, [actionState.actionPaths, clearSelection, setFocusedItem, triggerRefresh, showError])
|
|
156
|
+
|
|
157
|
+
// Confirm move
|
|
158
|
+
const confirmMove = useCallback(async (destination: string) => {
|
|
159
|
+
const paths = actionState.actionPaths
|
|
160
|
+
setActionState(prev => ({
|
|
161
|
+
...prev,
|
|
162
|
+
showMoveModal: false,
|
|
163
|
+
showProgress: true,
|
|
164
|
+
progressTitle: 'Moving Files',
|
|
165
|
+
progressState: {
|
|
166
|
+
current: 0,
|
|
167
|
+
total: paths.length,
|
|
168
|
+
percent: 0,
|
|
169
|
+
status: 'processing',
|
|
170
|
+
message: 'Moving files...',
|
|
171
|
+
},
|
|
172
|
+
}))
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
const response = await fetch('/api/studio/move', {
|
|
176
|
+
method: 'POST',
|
|
177
|
+
headers: { 'Content-Type': 'application/json' },
|
|
178
|
+
body: JSON.stringify({ paths, destination }),
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
if (!response.ok) {
|
|
182
|
+
const error = await response.json()
|
|
183
|
+
setProgressState({
|
|
184
|
+
status: 'error',
|
|
185
|
+
message: error.error || 'Move failed',
|
|
186
|
+
})
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const reader = response.body?.getReader()
|
|
191
|
+
const decoder = new TextDecoder()
|
|
192
|
+
|
|
193
|
+
if (reader) {
|
|
194
|
+
let buffer = ''
|
|
195
|
+
while (true) {
|
|
196
|
+
const { done, value } = await reader.read()
|
|
197
|
+
if (done) break
|
|
198
|
+
|
|
199
|
+
buffer += decoder.decode(value, { stream: true })
|
|
200
|
+
const lines = buffer.split('\n')
|
|
201
|
+
buffer = lines.pop() || ''
|
|
202
|
+
|
|
203
|
+
for (const line of lines) {
|
|
204
|
+
if (line.startsWith('data: ')) {
|
|
205
|
+
try {
|
|
206
|
+
const data = JSON.parse(line.slice(6))
|
|
207
|
+
|
|
208
|
+
if (data.type === 'start') {
|
|
209
|
+
setProgressState(prev => ({ ...prev, total: data.total }))
|
|
210
|
+
} else if (data.type === 'progress') {
|
|
211
|
+
setProgressState({
|
|
212
|
+
current: data.current,
|
|
213
|
+
total: data.total,
|
|
214
|
+
percent: Math.round((data.current / data.total) * 100),
|
|
215
|
+
status: 'processing',
|
|
216
|
+
message: data.message,
|
|
217
|
+
})
|
|
218
|
+
} else if (data.type === 'complete') {
|
|
219
|
+
setProgressState(prev => ({
|
|
220
|
+
...prev,
|
|
221
|
+
status: 'complete',
|
|
222
|
+
message: `Moved ${data.moved} file${data.moved !== 1 ? 's' : ''}${data.errors > 0 ? `, ${data.errors} error${data.errors !== 1 ? 's' : ''}` : ''}`,
|
|
223
|
+
}))
|
|
224
|
+
if (data.errors > 0 && data.errorMessages?.length > 0) {
|
|
225
|
+
showError('Move Failed', data.errorMessages.join('\n'))
|
|
226
|
+
}
|
|
227
|
+
clearSelection()
|
|
228
|
+
setFocusedItem(null)
|
|
229
|
+
triggerRefresh()
|
|
230
|
+
} else if (data.type === 'error') {
|
|
231
|
+
setProgressState(prev => ({
|
|
232
|
+
...prev,
|
|
233
|
+
status: 'error',
|
|
234
|
+
message: data.message || 'Unknown error',
|
|
235
|
+
}))
|
|
236
|
+
}
|
|
237
|
+
} catch { /* ignore parse errors */ }
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
} catch (error) {
|
|
243
|
+
console.error('Move error:', error)
|
|
244
|
+
setProgressState(prev => ({
|
|
245
|
+
...prev,
|
|
246
|
+
status: 'error',
|
|
247
|
+
message: 'Failed to move files. Check console for details.',
|
|
248
|
+
}))
|
|
249
|
+
}
|
|
250
|
+
}, [actionState.actionPaths, clearSelection, setFocusedItem, triggerRefresh, showError, setProgressState])
|
|
251
|
+
|
|
252
|
+
// Confirm sync (push to CDN)
|
|
253
|
+
const confirmSync = useCallback(async () => {
|
|
254
|
+
const paths = actionState.actionPaths
|
|
255
|
+
const imageKeys = paths.map(p => '/' + p.replace(/^public\//, ''))
|
|
256
|
+
|
|
257
|
+
setActionState(prev => ({
|
|
258
|
+
...prev,
|
|
259
|
+
showSyncConfirm: false,
|
|
260
|
+
showProgress: true,
|
|
261
|
+
progressTitle: 'Pushing to CDN',
|
|
262
|
+
progressState: {
|
|
263
|
+
current: 0,
|
|
264
|
+
total: imageKeys.length,
|
|
265
|
+
percent: 0,
|
|
266
|
+
status: 'processing',
|
|
267
|
+
message: 'Pushing to CDN...',
|
|
268
|
+
},
|
|
269
|
+
}))
|
|
270
|
+
|
|
271
|
+
let pushed = 0
|
|
272
|
+
const errors: string[] = []
|
|
273
|
+
|
|
274
|
+
for (let i = 0; i < imageKeys.length; i++) {
|
|
275
|
+
const imageKey = imageKeys[i]
|
|
276
|
+
|
|
277
|
+
setProgressState({
|
|
278
|
+
current: i + 1,
|
|
279
|
+
total: imageKeys.length,
|
|
280
|
+
percent: Math.round(((i + 1) / imageKeys.length) * 100),
|
|
281
|
+
status: 'processing',
|
|
282
|
+
message: `Pushing ${imageKey}...`,
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
try {
|
|
286
|
+
const response = await fetch('/api/studio/sync', {
|
|
287
|
+
method: 'POST',
|
|
288
|
+
headers: { 'Content-Type': 'application/json' },
|
|
289
|
+
body: JSON.stringify({ imageKeys: [imageKey] }),
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
if (response.ok) {
|
|
293
|
+
pushed++
|
|
294
|
+
} else {
|
|
295
|
+
const data = await response.json()
|
|
296
|
+
errors.push(`${imageKey}: ${data.error || 'Unknown error'}`)
|
|
297
|
+
}
|
|
298
|
+
} catch (error) {
|
|
299
|
+
errors.push(`${imageKey}: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
setProgressState({
|
|
304
|
+
current: imageKeys.length,
|
|
305
|
+
total: imageKeys.length,
|
|
306
|
+
percent: 100,
|
|
307
|
+
status: errors.length > 0 ? 'error' : 'complete',
|
|
308
|
+
message: `Pushed ${pushed} file${pushed !== 1 ? 's' : ''}${errors.length > 0 ? `, ${errors.length} error${errors.length !== 1 ? 's' : ''}` : ''}`,
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
if (errors.length > 0) {
|
|
312
|
+
showError('Push Errors', errors.join('\n'))
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
clearSelection()
|
|
316
|
+
triggerRefresh()
|
|
317
|
+
}, [actionState.actionPaths, clearSelection, triggerRefresh, showError, setProgressState])
|
|
318
|
+
|
|
319
|
+
// Confirm process (generate or remove thumbnails based on mode)
|
|
320
|
+
const confirmProcess = useCallback(async () => {
|
|
321
|
+
const paths = actionState.actionPaths
|
|
322
|
+
const mode = actionState.processMode
|
|
323
|
+
const imageKeys = paths.map(p => {
|
|
324
|
+
const key = p.replace(/^public\//, '')
|
|
325
|
+
return key.startsWith('/') ? key : `/${key}`
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
const isRemove = mode === 'remove'
|
|
329
|
+
const endpoint = isRemove ? '/api/studio/unprocess-stream' : '/api/studio/reprocess-stream'
|
|
330
|
+
const progressTitle = isRemove ? 'Removing Thumbnails' : 'Generating Thumbnails'
|
|
331
|
+
const progressMessage = isRemove ? 'Removing thumbnails...' : 'Generating thumbnails...'
|
|
332
|
+
|
|
333
|
+
setActionState(prev => ({
|
|
334
|
+
...prev,
|
|
335
|
+
showProcessConfirm: false,
|
|
336
|
+
showProgress: true,
|
|
337
|
+
progressTitle,
|
|
338
|
+
progressState: {
|
|
339
|
+
current: 0,
|
|
340
|
+
total: imageKeys.length,
|
|
341
|
+
percent: 0,
|
|
342
|
+
status: 'processing',
|
|
343
|
+
message: progressMessage,
|
|
344
|
+
},
|
|
345
|
+
}))
|
|
346
|
+
|
|
347
|
+
abortControllerRef.current = new AbortController()
|
|
348
|
+
const signal = abortControllerRef.current.signal
|
|
349
|
+
|
|
350
|
+
try {
|
|
351
|
+
const response = await fetch(endpoint, {
|
|
352
|
+
method: 'POST',
|
|
353
|
+
headers: { 'Content-Type': 'application/json' },
|
|
354
|
+
body: JSON.stringify({ imageKeys }),
|
|
355
|
+
signal,
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
if (!response.ok) {
|
|
359
|
+
const error = await response.json()
|
|
360
|
+
setProgressState({
|
|
361
|
+
current: 0,
|
|
362
|
+
total: imageKeys.length,
|
|
363
|
+
percent: 0,
|
|
364
|
+
status: 'error',
|
|
365
|
+
message: error.error || (isRemove ? 'Failed to remove thumbnails' : 'Processing failed'),
|
|
366
|
+
})
|
|
367
|
+
return
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const reader = response.body?.getReader()
|
|
371
|
+
const decoder = new TextDecoder()
|
|
372
|
+
|
|
373
|
+
if (reader) {
|
|
374
|
+
let buffer = ''
|
|
375
|
+
while (true) {
|
|
376
|
+
const { done, value } = await reader.read()
|
|
377
|
+
if (done) break
|
|
378
|
+
|
|
379
|
+
buffer += decoder.decode(value, { stream: true })
|
|
380
|
+
const lines = buffer.split('\n')
|
|
381
|
+
buffer = lines.pop() || ''
|
|
382
|
+
|
|
383
|
+
for (const line of lines) {
|
|
384
|
+
if (line.startsWith('data: ')) {
|
|
385
|
+
try {
|
|
386
|
+
const data = JSON.parse(line.slice(6))
|
|
387
|
+
|
|
388
|
+
if (data.type === 'start') {
|
|
389
|
+
setProgressState(prev => ({
|
|
390
|
+
...prev,
|
|
391
|
+
total: data.total,
|
|
392
|
+
}))
|
|
393
|
+
} else if (data.type === 'progress') {
|
|
394
|
+
setProgressState({
|
|
395
|
+
current: data.current,
|
|
396
|
+
total: data.total,
|
|
397
|
+
percent: data.percent,
|
|
398
|
+
status: 'processing',
|
|
399
|
+
message: data.message,
|
|
400
|
+
})
|
|
401
|
+
} else if (data.type === 'cleanup') {
|
|
402
|
+
setProgressState(prev => ({
|
|
403
|
+
...prev,
|
|
404
|
+
status: 'cleanup',
|
|
405
|
+
message: data.message,
|
|
406
|
+
}))
|
|
407
|
+
} else if (data.type === 'complete') {
|
|
408
|
+
setProgressState({
|
|
409
|
+
current: data.processed,
|
|
410
|
+
total: data.processed,
|
|
411
|
+
percent: 100,
|
|
412
|
+
status: data.errors > 0 ? 'error' : 'complete',
|
|
413
|
+
message: data.message,
|
|
414
|
+
})
|
|
415
|
+
triggerRefresh()
|
|
416
|
+
} else if (data.type === 'error') {
|
|
417
|
+
setProgressState(prev => ({
|
|
418
|
+
...prev,
|
|
419
|
+
status: 'error',
|
|
420
|
+
message: data.message,
|
|
421
|
+
}))
|
|
422
|
+
}
|
|
423
|
+
} catch { /* ignore parse errors */ }
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
} catch (error) {
|
|
429
|
+
if (signal.aborted) {
|
|
430
|
+
setProgressState(prev => ({
|
|
431
|
+
...prev,
|
|
432
|
+
status: 'stopped',
|
|
433
|
+
message: isRemove ? 'Removal stopped by user' : 'Processing stopped by user',
|
|
434
|
+
}))
|
|
435
|
+
} else {
|
|
436
|
+
console.error('Processing error:', error)
|
|
437
|
+
setProgressState({
|
|
438
|
+
current: 0,
|
|
439
|
+
total: imageKeys.length,
|
|
440
|
+
percent: 0,
|
|
441
|
+
status: 'error',
|
|
442
|
+
message: isRemove ? 'Failed to remove thumbnails. Check console for details.' : 'Processing failed. Check console for details.',
|
|
443
|
+
})
|
|
444
|
+
}
|
|
445
|
+
} finally {
|
|
446
|
+
abortControllerRef.current = null
|
|
447
|
+
}
|
|
448
|
+
}, [actionState.actionPaths, actionState.processMode, triggerRefresh, setProgressState])
|
|
449
|
+
|
|
450
|
+
// Delete orphans
|
|
451
|
+
const deleteOrphans = useCallback(async () => {
|
|
452
|
+
const orphanedFiles = actionState.progressState.orphanedFiles
|
|
453
|
+
if (!orphanedFiles || orphanedFiles.length === 0) return
|
|
454
|
+
|
|
455
|
+
try {
|
|
456
|
+
const response = await fetch('/api/studio/delete-orphans', {
|
|
457
|
+
method: 'POST',
|
|
458
|
+
headers: { 'Content-Type': 'application/json' },
|
|
459
|
+
body: JSON.stringify({ files: orphanedFiles }),
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
if (response.ok) {
|
|
463
|
+
setProgressState(prev => ({
|
|
464
|
+
...prev,
|
|
465
|
+
orphanedFiles: undefined,
|
|
466
|
+
message: prev.message?.replace(/Found \d+ orphaned thumbnail\(s\).*/, 'Orphaned thumbnails deleted.'),
|
|
467
|
+
}))
|
|
468
|
+
triggerRefresh()
|
|
469
|
+
} else {
|
|
470
|
+
const error = await response.json()
|
|
471
|
+
showError('Delete Failed', error.error || 'Failed to delete orphaned files')
|
|
472
|
+
}
|
|
473
|
+
} catch (error) {
|
|
474
|
+
console.error('Delete orphans error:', error)
|
|
475
|
+
showError('Delete Failed', 'Failed to delete orphaned files. Check console for details.')
|
|
476
|
+
}
|
|
477
|
+
}, [actionState.progressState.orphanedFiles, triggerRefresh, showError, setProgressState])
|
|
478
|
+
|
|
479
|
+
return {
|
|
480
|
+
actionState,
|
|
481
|
+
setActionState,
|
|
482
|
+
abortController: abortControllerRef.current,
|
|
483
|
+
requestDelete,
|
|
484
|
+
requestMove,
|
|
485
|
+
requestSync,
|
|
486
|
+
requestProcess,
|
|
487
|
+
setProcessMode,
|
|
488
|
+
cancelAction,
|
|
489
|
+
closeProgress,
|
|
490
|
+
stopProcessing,
|
|
491
|
+
confirmDelete,
|
|
492
|
+
confirmMove,
|
|
493
|
+
confirmSync,
|
|
494
|
+
confirmProcess,
|
|
495
|
+
deleteOrphans,
|
|
496
|
+
}
|
|
497
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import path from 'path'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Workspace configuration for standalone Studio.
|
|
5
|
+
*
|
|
6
|
+
* The workspace path determines where Studio looks for:
|
|
7
|
+
* - public/ folder (images, media)
|
|
8
|
+
* - _data/_studio.json (metadata)
|
|
9
|
+
* - src/app/ (for favicon generation)
|
|
10
|
+
*
|
|
11
|
+
* Set via STUDIO_WORKSPACE environment variable or defaults to cwd.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
let workspacePath: string | null = null
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Get the workspace path. Reads from STUDIO_WORKSPACE env var on first call.
|
|
18
|
+
*/
|
|
19
|
+
export function getWorkspace(): string {
|
|
20
|
+
if (workspacePath === null) {
|
|
21
|
+
workspacePath = process.env.STUDIO_WORKSPACE || process.cwd()
|
|
22
|
+
}
|
|
23
|
+
return workspacePath
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Get the path to the public folder.
|
|
28
|
+
*/
|
|
29
|
+
export function getPublicPath(...segments: string[]): string {
|
|
30
|
+
return path.join(getWorkspace(), 'public', ...segments)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Get the path to the _data folder.
|
|
35
|
+
*/
|
|
36
|
+
export function getDataPath(...segments: string[]): string {
|
|
37
|
+
return path.join(getWorkspace(), '_data', ...segments)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get the path to the src/app folder (for favicon generation).
|
|
42
|
+
*/
|
|
43
|
+
export function getSrcAppPath(...segments: string[]): string {
|
|
44
|
+
return path.join(getWorkspace(), 'src', 'app', ...segments)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Join paths relative to the workspace root.
|
|
49
|
+
*/
|
|
50
|
+
export function getWorkspacePath(...segments: string[]): string {
|
|
51
|
+
return path.join(getWorkspace(), ...segments)
|
|
52
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
2
|
+
import sharp from 'sharp'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import fs from 'fs/promises'
|
|
5
|
+
import { getPublicPath, getSrcAppPath } from '../config'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generate favicon variants from a source image (streaming)
|
|
9
|
+
*
|
|
10
|
+
* Takes a favicon.png or favicon.jpg and generates:
|
|
11
|
+
* - favicon.ico (48x48) - Classic ICO format
|
|
12
|
+
* - icon.png (32x32) - Standard favicon
|
|
13
|
+
* - apple-icon.png (180x180) - Apple touch icon
|
|
14
|
+
*
|
|
15
|
+
* All outputs are saved to src/app/ for Next.js metadata
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const FAVICON_CONFIGS = [
|
|
19
|
+
{ name: 'favicon.ico', size: 48 },
|
|
20
|
+
{ name: 'icon.png', size: 32 },
|
|
21
|
+
{ name: 'apple-icon.png', size: 180 },
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
export async function handleGenerateFavicon(request: NextRequest) {
|
|
25
|
+
const encoder = new TextEncoder()
|
|
26
|
+
|
|
27
|
+
let imagePath: string
|
|
28
|
+
try {
|
|
29
|
+
const body = await request.json() as { imagePath: string }
|
|
30
|
+
imagePath = body.imagePath
|
|
31
|
+
|
|
32
|
+
if (!imagePath) {
|
|
33
|
+
return NextResponse.json({ error: 'No image path provided' }, { status: 400 })
|
|
34
|
+
}
|
|
35
|
+
} catch {
|
|
36
|
+
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Validate filename is favicon.png or favicon.jpg
|
|
40
|
+
const fileName = path.basename(imagePath).toLowerCase()
|
|
41
|
+
if (fileName !== 'favicon.png' && fileName !== 'favicon.jpg') {
|
|
42
|
+
return NextResponse.json({
|
|
43
|
+
error: 'Source file must be named favicon.png or favicon.jpg'
|
|
44
|
+
}, { status: 400 })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Build full path to source file
|
|
48
|
+
const sourcePath = getPublicPath(imagePath.replace(/^\//, ''))
|
|
49
|
+
|
|
50
|
+
// Check if source file exists
|
|
51
|
+
try {
|
|
52
|
+
await fs.access(sourcePath)
|
|
53
|
+
} catch {
|
|
54
|
+
return NextResponse.json({ error: 'Source file not found' }, { status: 404 })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Verify the source is a valid image
|
|
58
|
+
let metadata
|
|
59
|
+
try {
|
|
60
|
+
metadata = await sharp(sourcePath).metadata()
|
|
61
|
+
} catch {
|
|
62
|
+
return NextResponse.json({ error: 'Source file is not a valid image' }, { status: 400 })
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Output directory is src/app/
|
|
66
|
+
const outputDir = getSrcAppPath()
|
|
67
|
+
|
|
68
|
+
// Check output directory exists
|
|
69
|
+
try {
|
|
70
|
+
await fs.access(outputDir)
|
|
71
|
+
} catch {
|
|
72
|
+
return NextResponse.json({
|
|
73
|
+
error: 'Output directory src/app/ not found'
|
|
74
|
+
}, { status: 500 })
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const stream = new ReadableStream({
|
|
78
|
+
async start(controller) {
|
|
79
|
+
const sendEvent = (data: object) => {
|
|
80
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const total = FAVICON_CONFIGS.length
|
|
85
|
+
const generated: string[] = []
|
|
86
|
+
const errors: string[] = []
|
|
87
|
+
|
|
88
|
+
sendEvent({
|
|
89
|
+
type: 'start',
|
|
90
|
+
total,
|
|
91
|
+
sourceSize: `${metadata.width}x${metadata.height}`,
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
for (let i = 0; i < FAVICON_CONFIGS.length; i++) {
|
|
95
|
+
const config = FAVICON_CONFIGS[i]
|
|
96
|
+
|
|
97
|
+
sendEvent({
|
|
98
|
+
type: 'progress',
|
|
99
|
+
current: i + 1,
|
|
100
|
+
total,
|
|
101
|
+
percent: Math.round(((i + 1) / total) * 100),
|
|
102
|
+
message: `Generating ${config.name} (${config.size}x${config.size})...`
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const outputPath = path.join(outputDir, config.name)
|
|
107
|
+
|
|
108
|
+
await sharp(sourcePath)
|
|
109
|
+
.resize(config.size, config.size, {
|
|
110
|
+
fit: 'cover',
|
|
111
|
+
position: 'center',
|
|
112
|
+
})
|
|
113
|
+
.png({ quality: 100 })
|
|
114
|
+
.toFile(outputPath)
|
|
115
|
+
|
|
116
|
+
generated.push(config.name)
|
|
117
|
+
} catch (error) {
|
|
118
|
+
console.error(`Failed to generate ${config.name}:`, error)
|
|
119
|
+
errors.push(config.name)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Build completion message
|
|
124
|
+
let message = `Generated ${generated.length} favicon${generated.length !== 1 ? 's' : ''} to src/app/.`
|
|
125
|
+
if (errors.length > 0) {
|
|
126
|
+
message += ` ${errors.length} failed.`
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
sendEvent({
|
|
130
|
+
type: 'complete',
|
|
131
|
+
processed: generated.length,
|
|
132
|
+
errors: errors.length,
|
|
133
|
+
message,
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
controller.close()
|
|
137
|
+
} catch (error) {
|
|
138
|
+
console.error('Favicon generation error:', error)
|
|
139
|
+
sendEvent({ type: 'error', message: 'Failed to generate favicons' })
|
|
140
|
+
controller.close()
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
return new Response(stream, {
|
|
146
|
+
headers: {
|
|
147
|
+
'Content-Type': 'text/event-stream',
|
|
148
|
+
'Cache-Control': 'no-cache',
|
|
149
|
+
Connection: 'keep-alive',
|
|
150
|
+
},
|
|
151
|
+
})
|
|
152
|
+
}
|