@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,784 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
2
|
+
import { promises as fs } from 'fs'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import sharp from 'sharp'
|
|
5
|
+
import type { MetaEntry } from '../types'
|
|
6
|
+
import { getAllThumbnailPaths, isProcessed } from '../types'
|
|
7
|
+
import {
|
|
8
|
+
loadMeta,
|
|
9
|
+
saveMeta,
|
|
10
|
+
isImageFile,
|
|
11
|
+
isMediaFile,
|
|
12
|
+
getCdnUrls,
|
|
13
|
+
downloadFromCdn,
|
|
14
|
+
downloadFromRemoteUrl,
|
|
15
|
+
uploadOriginalToCdn,
|
|
16
|
+
uploadToCdn,
|
|
17
|
+
deleteFromCdn,
|
|
18
|
+
deleteLocalThumbnails,
|
|
19
|
+
processImage,
|
|
20
|
+
} from './utils'
|
|
21
|
+
import { getPublicPath, getWorkspacePath } from '../config'
|
|
22
|
+
|
|
23
|
+
export async function handleUpload(request: NextRequest) {
|
|
24
|
+
try {
|
|
25
|
+
const formData = await request.formData()
|
|
26
|
+
const file = formData.get('file') as File | null
|
|
27
|
+
const targetPath = formData.get('path') as string || 'public'
|
|
28
|
+
|
|
29
|
+
if (!file) {
|
|
30
|
+
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const bytes = await file.arrayBuffer()
|
|
34
|
+
const buffer = Buffer.from(bytes)
|
|
35
|
+
|
|
36
|
+
const fileName = file.name
|
|
37
|
+
const ext = path.extname(fileName).toLowerCase()
|
|
38
|
+
|
|
39
|
+
const isImage = isImageFile(fileName)
|
|
40
|
+
const isMedia = isMediaFile(fileName)
|
|
41
|
+
|
|
42
|
+
const meta = await loadMeta()
|
|
43
|
+
|
|
44
|
+
let relativeDir = ''
|
|
45
|
+
if (targetPath === 'public') {
|
|
46
|
+
relativeDir = ''
|
|
47
|
+
} else if (targetPath.startsWith('public/')) {
|
|
48
|
+
relativeDir = targetPath.replace('public/', '')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (relativeDir === 'images' || relativeDir.startsWith('images/')) {
|
|
52
|
+
return NextResponse.json(
|
|
53
|
+
{ error: 'Cannot upload to images/ folder. Upload to public/ instead - thumbnails are generated automatically.' },
|
|
54
|
+
{ status: 400 }
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Build the meta key
|
|
59
|
+
let imageKey = '/' + (relativeDir ? `${relativeDir}/${fileName}` : fileName)
|
|
60
|
+
|
|
61
|
+
// Check for collision - rename if needed
|
|
62
|
+
if (meta[imageKey]) {
|
|
63
|
+
const baseName = path.basename(fileName, ext)
|
|
64
|
+
let counter = 1
|
|
65
|
+
let newFileName = `${baseName}-${counter}${ext}`
|
|
66
|
+
let newKey = '/' + (relativeDir ? `${relativeDir}/${newFileName}` : newFileName)
|
|
67
|
+
|
|
68
|
+
while (meta[newKey]) {
|
|
69
|
+
counter++
|
|
70
|
+
newFileName = `${baseName}-${counter}${ext}`
|
|
71
|
+
newKey = '/' + (relativeDir ? `${relativeDir}/${newFileName}` : newFileName)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
imageKey = newKey
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Extract actual filename from key
|
|
78
|
+
const actualFileName = path.basename(imageKey)
|
|
79
|
+
|
|
80
|
+
const uploadDir = getPublicPath( relativeDir)
|
|
81
|
+
await fs.mkdir(uploadDir, { recursive: true })
|
|
82
|
+
await fs.writeFile(path.join(uploadDir, actualFileName), buffer)
|
|
83
|
+
|
|
84
|
+
if (!isMedia) {
|
|
85
|
+
return NextResponse.json({
|
|
86
|
+
success: true,
|
|
87
|
+
message: 'File uploaded (not a media file)',
|
|
88
|
+
path: `public/${relativeDir ? relativeDir + '/' : ''}${actualFileName}`
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Add to meta
|
|
93
|
+
if (isImage && ext !== '.svg') {
|
|
94
|
+
// Read dimensions for images
|
|
95
|
+
try {
|
|
96
|
+
const metadata = await sharp(buffer).metadata()
|
|
97
|
+
meta[imageKey] = {
|
|
98
|
+
o: { w: metadata.width || 0, h: metadata.height || 0 },
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
meta[imageKey] = { o: { w: 0, h: 0 } }
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
// Non-image media or SVG
|
|
105
|
+
meta[imageKey] = {}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
await saveMeta(meta)
|
|
109
|
+
|
|
110
|
+
return NextResponse.json({
|
|
111
|
+
success: true,
|
|
112
|
+
imageKey,
|
|
113
|
+
message: 'File uploaded. Run "Process Images" to generate thumbnails.'
|
|
114
|
+
})
|
|
115
|
+
} catch (error) {
|
|
116
|
+
console.error('Failed to upload:', error)
|
|
117
|
+
const message = error instanceof Error ? error.message : 'Unknown error'
|
|
118
|
+
return NextResponse.json({ error: `Failed to upload file: ${message}` }, { status: 500 })
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function handleDelete(request: NextRequest) {
|
|
123
|
+
try {
|
|
124
|
+
const { paths } = await request.json() as { paths: string[] }
|
|
125
|
+
|
|
126
|
+
if (!paths || !Array.isArray(paths) || paths.length === 0) {
|
|
127
|
+
return NextResponse.json({ error: 'No paths provided' }, { status: 400 })
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const meta = await loadMeta()
|
|
131
|
+
const deleted: string[] = []
|
|
132
|
+
const errors: string[] = []
|
|
133
|
+
|
|
134
|
+
for (const itemPath of paths) {
|
|
135
|
+
try {
|
|
136
|
+
if (!itemPath.startsWith('public/')) {
|
|
137
|
+
errors.push(`Invalid path: ${itemPath}`)
|
|
138
|
+
continue
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const absolutePath = getWorkspacePath(itemPath)
|
|
142
|
+
const imageKey = '/' + itemPath.replace(/^public\//, '')
|
|
143
|
+
|
|
144
|
+
// Check if this is in meta (could be synced with no local file)
|
|
145
|
+
const entry = meta[imageKey] as MetaEntry | undefined
|
|
146
|
+
const isPushedToCloud = entry?.c !== undefined
|
|
147
|
+
|
|
148
|
+
// Try to delete local file/folder
|
|
149
|
+
try {
|
|
150
|
+
const stats = await fs.stat(absolutePath)
|
|
151
|
+
|
|
152
|
+
if (stats.isDirectory()) {
|
|
153
|
+
await fs.rm(absolutePath, { recursive: true })
|
|
154
|
+
|
|
155
|
+
// Remove all meta entries under this folder
|
|
156
|
+
const prefix = imageKey + '/'
|
|
157
|
+
for (const key of Object.keys(meta)) {
|
|
158
|
+
if (key.startsWith(prefix) || key === imageKey) {
|
|
159
|
+
const keyEntry = meta[key] as MetaEntry | undefined
|
|
160
|
+
// Also delete local thumbnails if not synced
|
|
161
|
+
if (keyEntry && keyEntry.c === undefined) {
|
|
162
|
+
for (const thumbPath of getAllThumbnailPaths(key)) {
|
|
163
|
+
const absoluteThumbPath = getPublicPath( thumbPath)
|
|
164
|
+
try { await fs.unlink(absoluteThumbPath) } catch { /* ignore */ }
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
delete meta[key]
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
await fs.unlink(absolutePath)
|
|
172
|
+
|
|
173
|
+
const isInImagesFolder = itemPath.startsWith('public/images/')
|
|
174
|
+
|
|
175
|
+
if (!isInImagesFolder && entry) {
|
|
176
|
+
// Delete local thumbnails if not synced
|
|
177
|
+
if (!isPushedToCloud) {
|
|
178
|
+
for (const thumbPath of getAllThumbnailPaths(imageKey)) {
|
|
179
|
+
const absoluteThumbPath = getPublicPath( thumbPath)
|
|
180
|
+
try { await fs.unlink(absoluteThumbPath) } catch { /* ignore */ }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
delete meta[imageKey]
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
} catch {
|
|
187
|
+
// File doesn't exist locally - might be synced
|
|
188
|
+
if (entry) {
|
|
189
|
+
// Just remove from meta (file is on CDN)
|
|
190
|
+
delete meta[imageKey]
|
|
191
|
+
} else {
|
|
192
|
+
// Check if it's a folder prefix in meta
|
|
193
|
+
const prefix = imageKey + '/'
|
|
194
|
+
let foundAny = false
|
|
195
|
+
for (const key of Object.keys(meta)) {
|
|
196
|
+
if (key.startsWith(prefix)) {
|
|
197
|
+
delete meta[key]
|
|
198
|
+
foundAny = true
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (!foundAny) {
|
|
202
|
+
errors.push(`Not found: ${itemPath}`)
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
deleted.push(itemPath)
|
|
209
|
+
} catch (error) {
|
|
210
|
+
console.error(`Failed to delete ${itemPath}:`, error)
|
|
211
|
+
errors.push(itemPath)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
await saveMeta(meta)
|
|
216
|
+
|
|
217
|
+
return NextResponse.json({
|
|
218
|
+
success: true,
|
|
219
|
+
deleted,
|
|
220
|
+
errors: errors.length > 0 ? errors : undefined,
|
|
221
|
+
})
|
|
222
|
+
} catch (error) {
|
|
223
|
+
console.error('Failed to delete:', error)
|
|
224
|
+
return NextResponse.json({ error: 'Failed to delete files' }, { status: 500 })
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function handleCreateFolder(request: NextRequest) {
|
|
229
|
+
try {
|
|
230
|
+
const { parentPath, name } = await request.json()
|
|
231
|
+
|
|
232
|
+
if (!name || typeof name !== 'string') {
|
|
233
|
+
return NextResponse.json({ error: 'Folder name is required' }, { status: 400 })
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const sanitizedName = name.replace(/[<>:"/\\|?*]/g, '').trim()
|
|
237
|
+
if (!sanitizedName) {
|
|
238
|
+
return NextResponse.json({ error: 'Invalid folder name' }, { status: 400 })
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const safePath = (parentPath || 'public').replace(/\.\./g, '')
|
|
242
|
+
const folderPath = getWorkspacePath(safePath, sanitizedName)
|
|
243
|
+
|
|
244
|
+
if (!folderPath.startsWith(getPublicPath())) {
|
|
245
|
+
return NextResponse.json({ error: 'Invalid path' }, { status: 400 })
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
await fs.access(folderPath)
|
|
250
|
+
return NextResponse.json({ error: 'A folder with this name already exists' }, { status: 400 })
|
|
251
|
+
} catch {
|
|
252
|
+
// Good - folder doesn't exist
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
await fs.mkdir(folderPath, { recursive: true })
|
|
256
|
+
|
|
257
|
+
return NextResponse.json({ success: true, path: path.join(safePath, sanitizedName) })
|
|
258
|
+
} catch (error) {
|
|
259
|
+
console.error('Failed to create folder:', error)
|
|
260
|
+
return NextResponse.json({ error: 'Failed to create folder' }, { status: 500 })
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export async function handleRename(request: NextRequest) {
|
|
265
|
+
try {
|
|
266
|
+
const { oldPath, newName } = await request.json()
|
|
267
|
+
|
|
268
|
+
if (!oldPath || !newName) {
|
|
269
|
+
return NextResponse.json({ error: 'Path and new name are required' }, { status: 400 })
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const sanitizedName = newName.replace(/[<>:"/\\|?*]/g, '').trim()
|
|
273
|
+
if (!sanitizedName) {
|
|
274
|
+
return NextResponse.json({ error: 'Invalid name' }, { status: 400 })
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const safePath = oldPath.replace(/\.\./g, '')
|
|
278
|
+
const absoluteOldPath = getWorkspacePath(safePath)
|
|
279
|
+
const parentDir = path.dirname(absoluteOldPath)
|
|
280
|
+
const absoluteNewPath = path.join(parentDir, sanitizedName)
|
|
281
|
+
|
|
282
|
+
if (!absoluteOldPath.startsWith(getPublicPath())) {
|
|
283
|
+
return NextResponse.json({ error: 'Invalid path' }, { status: 400 })
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
await fs.access(absoluteOldPath)
|
|
288
|
+
} catch {
|
|
289
|
+
return NextResponse.json({ error: 'File or folder not found' }, { status: 404 })
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
await fs.access(absoluteNewPath)
|
|
294
|
+
return NextResponse.json({ error: 'An item with this name already exists' }, { status: 400 })
|
|
295
|
+
} catch {
|
|
296
|
+
// Good - new path doesn't exist
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const stats = await fs.stat(absoluteOldPath)
|
|
300
|
+
const isFile = stats.isFile()
|
|
301
|
+
const isImage = isFile && isImageFile(path.basename(oldPath))
|
|
302
|
+
|
|
303
|
+
await fs.rename(absoluteOldPath, absoluteNewPath)
|
|
304
|
+
|
|
305
|
+
if (isImage) {
|
|
306
|
+
const meta = await loadMeta()
|
|
307
|
+
const oldRelativePath = safePath.replace(/^public\//, '')
|
|
308
|
+
const newRelativePath = path.join(path.dirname(oldRelativePath), sanitizedName)
|
|
309
|
+
const oldKey = '/' + oldRelativePath
|
|
310
|
+
const newKey = '/' + newRelativePath
|
|
311
|
+
|
|
312
|
+
if (meta[oldKey]) {
|
|
313
|
+
const entry = meta[oldKey]
|
|
314
|
+
|
|
315
|
+
const oldThumbPaths = getAllThumbnailPaths(oldKey)
|
|
316
|
+
const newThumbPaths = getAllThumbnailPaths(newKey)
|
|
317
|
+
|
|
318
|
+
for (let i = 0; i < oldThumbPaths.length; i++) {
|
|
319
|
+
const oldThumbPath = getPublicPath( oldThumbPaths[i])
|
|
320
|
+
const newThumbPath = getPublicPath( newThumbPaths[i])
|
|
321
|
+
|
|
322
|
+
await fs.mkdir(path.dirname(newThumbPath), { recursive: true })
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
await fs.rename(oldThumbPath, newThumbPath)
|
|
326
|
+
} catch {
|
|
327
|
+
// Thumbnail might not exist
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
delete meta[oldKey]
|
|
332
|
+
meta[newKey] = entry
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
await saveMeta(meta)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const newPath = path.join(path.dirname(safePath), sanitizedName)
|
|
339
|
+
return NextResponse.json({ success: true, newPath })
|
|
340
|
+
} catch (error) {
|
|
341
|
+
console.error('Failed to rename:', error)
|
|
342
|
+
return NextResponse.json({ error: 'Failed to rename' }, { status: 500 })
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export async function handleMoveStream(request: NextRequest) {
|
|
347
|
+
const encoder = new TextEncoder()
|
|
348
|
+
|
|
349
|
+
const stream = new ReadableStream({
|
|
350
|
+
async start(controller) {
|
|
351
|
+
const sendEvent = (data: object) => {
|
|
352
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
try {
|
|
356
|
+
const { paths, destination } = await request.json()
|
|
357
|
+
|
|
358
|
+
if (!paths || !Array.isArray(paths) || paths.length === 0) {
|
|
359
|
+
sendEvent({ type: 'error', message: 'Paths are required' })
|
|
360
|
+
controller.close()
|
|
361
|
+
return
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (!destination || typeof destination !== 'string') {
|
|
365
|
+
sendEvent({ type: 'error', message: 'Destination is required' })
|
|
366
|
+
controller.close()
|
|
367
|
+
return
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const safeDestination = destination.replace(/\.\./g, '')
|
|
371
|
+
const absoluteDestination = getWorkspacePath(safeDestination)
|
|
372
|
+
|
|
373
|
+
if (!absoluteDestination.startsWith(getPublicPath())) {
|
|
374
|
+
sendEvent({ type: 'error', message: 'Invalid destination' })
|
|
375
|
+
controller.close()
|
|
376
|
+
return
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Ensure destination folder exists
|
|
380
|
+
await fs.mkdir(absoluteDestination, { recursive: true })
|
|
381
|
+
|
|
382
|
+
const meta = await loadMeta()
|
|
383
|
+
const cdnUrls = getCdnUrls(meta)
|
|
384
|
+
const r2PublicUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL?.replace(/\/$/, '') || ''
|
|
385
|
+
|
|
386
|
+
const moved: string[] = []
|
|
387
|
+
const errors: string[] = []
|
|
388
|
+
const total = paths.length
|
|
389
|
+
|
|
390
|
+
sendEvent({ type: 'start', total })
|
|
391
|
+
|
|
392
|
+
for (let i = 0; i < paths.length; i++) {
|
|
393
|
+
const itemPath = paths[i]
|
|
394
|
+
const safePath = itemPath.replace(/\.\./g, '')
|
|
395
|
+
const itemName = path.basename(safePath)
|
|
396
|
+
const newAbsolutePath = path.join(absoluteDestination, itemName)
|
|
397
|
+
|
|
398
|
+
// Build meta keys
|
|
399
|
+
const oldRelativePath = safePath.replace(/^public\//, '')
|
|
400
|
+
const newRelativePath = path.join(safeDestination.replace(/^public\//, ''), itemName)
|
|
401
|
+
const oldKey = '/' + oldRelativePath
|
|
402
|
+
const newKey = '/' + newRelativePath
|
|
403
|
+
|
|
404
|
+
sendEvent({
|
|
405
|
+
type: 'progress',
|
|
406
|
+
current: i + 1,
|
|
407
|
+
total,
|
|
408
|
+
percent: Math.round(((i + 1) / total) * 100),
|
|
409
|
+
currentFile: itemName,
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
// Check if destination already exists in meta
|
|
413
|
+
if (meta[newKey]) {
|
|
414
|
+
errors.push(`${itemName} already exists in destination`)
|
|
415
|
+
continue
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const entry = meta[oldKey] as MetaEntry | undefined
|
|
419
|
+
const isImage = isImageFile(itemName)
|
|
420
|
+
|
|
421
|
+
// Determine if cloud or remote
|
|
422
|
+
const isInCloud = entry?.c !== undefined
|
|
423
|
+
const fileCdnUrl = isInCloud && entry.c !== undefined ? cdnUrls[entry.c] : undefined
|
|
424
|
+
const isRemote = isInCloud && (!r2PublicUrl || fileCdnUrl !== r2PublicUrl)
|
|
425
|
+
const isPushedToR2 = isInCloud && r2PublicUrl && fileCdnUrl === r2PublicUrl
|
|
426
|
+
const hasProcessedThumbnails = isProcessed(entry)
|
|
427
|
+
|
|
428
|
+
try {
|
|
429
|
+
if (isRemote && isImage) {
|
|
430
|
+
// ===== REMOTE IMAGE =====
|
|
431
|
+
const remoteUrl = `${fileCdnUrl}${oldKey}`
|
|
432
|
+
const buffer = await downloadFromRemoteUrl(remoteUrl)
|
|
433
|
+
|
|
434
|
+
await fs.mkdir(path.dirname(newAbsolutePath), { recursive: true })
|
|
435
|
+
await fs.writeFile(newAbsolutePath, buffer)
|
|
436
|
+
|
|
437
|
+
const newEntry: MetaEntry = {
|
|
438
|
+
o: entry?.o,
|
|
439
|
+
b: entry?.b,
|
|
440
|
+
}
|
|
441
|
+
delete meta[oldKey]
|
|
442
|
+
meta[newKey] = newEntry
|
|
443
|
+
moved.push(itemPath)
|
|
444
|
+
|
|
445
|
+
} else if (isPushedToR2 && isImage) {
|
|
446
|
+
// ===== CLOUD IMAGE (R2) =====
|
|
447
|
+
const buffer = await downloadFromCdn(oldKey)
|
|
448
|
+
|
|
449
|
+
await fs.mkdir(path.dirname(newAbsolutePath), { recursive: true })
|
|
450
|
+
await fs.writeFile(newAbsolutePath, buffer)
|
|
451
|
+
|
|
452
|
+
let newEntry: MetaEntry = {
|
|
453
|
+
o: entry?.o,
|
|
454
|
+
b: entry?.b,
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (hasProcessedThumbnails) {
|
|
458
|
+
const processedEntry = await processImage(buffer, newKey)
|
|
459
|
+
newEntry = { ...newEntry, ...processedEntry }
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
await uploadOriginalToCdn(newKey)
|
|
463
|
+
|
|
464
|
+
if (hasProcessedThumbnails) {
|
|
465
|
+
await uploadToCdn(newKey)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
await deleteFromCdn(oldKey, hasProcessedThumbnails)
|
|
469
|
+
|
|
470
|
+
try { await fs.unlink(newAbsolutePath) } catch { /* ignore */ }
|
|
471
|
+
if (hasProcessedThumbnails) {
|
|
472
|
+
await deleteLocalThumbnails(newKey)
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
newEntry.c = entry?.c
|
|
476
|
+
|
|
477
|
+
delete meta[oldKey]
|
|
478
|
+
meta[newKey] = newEntry
|
|
479
|
+
moved.push(itemPath)
|
|
480
|
+
|
|
481
|
+
} else {
|
|
482
|
+
// ===== LOCAL FILE =====
|
|
483
|
+
const absolutePath = getWorkspacePath(safePath)
|
|
484
|
+
|
|
485
|
+
if (absoluteDestination.startsWith(absolutePath + path.sep)) {
|
|
486
|
+
errors.push(`Cannot move ${itemName} into itself`)
|
|
487
|
+
continue
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
try {
|
|
491
|
+
await fs.access(absolutePath)
|
|
492
|
+
} catch {
|
|
493
|
+
errors.push(`${itemName} not found`)
|
|
494
|
+
continue
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
try {
|
|
498
|
+
await fs.access(newAbsolutePath)
|
|
499
|
+
errors.push(`${itemName} already exists in destination`)
|
|
500
|
+
continue
|
|
501
|
+
} catch {
|
|
502
|
+
// Good
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
await fs.rename(absolutePath, newAbsolutePath)
|
|
506
|
+
|
|
507
|
+
const stats = await fs.stat(newAbsolutePath)
|
|
508
|
+
if (stats.isFile() && isImage && entry) {
|
|
509
|
+
const oldThumbPaths = getAllThumbnailPaths(oldKey)
|
|
510
|
+
const newThumbPaths = getAllThumbnailPaths(newKey)
|
|
511
|
+
|
|
512
|
+
for (let j = 0; j < oldThumbPaths.length; j++) {
|
|
513
|
+
const oldThumbPath = getPublicPath( oldThumbPaths[j])
|
|
514
|
+
const newThumbPath = getPublicPath( newThumbPaths[j])
|
|
515
|
+
|
|
516
|
+
await fs.mkdir(path.dirname(newThumbPath), { recursive: true })
|
|
517
|
+
|
|
518
|
+
try {
|
|
519
|
+
await fs.rename(oldThumbPath, newThumbPath)
|
|
520
|
+
} catch {
|
|
521
|
+
// Thumbnail might not exist
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
delete meta[oldKey]
|
|
526
|
+
meta[newKey] = entry
|
|
527
|
+
} else if (stats.isDirectory()) {
|
|
528
|
+
const oldPrefix = oldKey + '/'
|
|
529
|
+
const newPrefix = newKey + '/'
|
|
530
|
+
|
|
531
|
+
for (const key of Object.keys(meta)) {
|
|
532
|
+
if (key.startsWith(oldPrefix)) {
|
|
533
|
+
const newMetaKey = newPrefix + key.slice(oldPrefix.length)
|
|
534
|
+
meta[newMetaKey] = meta[key]
|
|
535
|
+
delete meta[key]
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
moved.push(itemPath)
|
|
541
|
+
}
|
|
542
|
+
} catch (err) {
|
|
543
|
+
console.error(`Failed to move ${itemName}:`, err)
|
|
544
|
+
errors.push(`Failed to move ${itemName}`)
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
await saveMeta(meta)
|
|
549
|
+
|
|
550
|
+
sendEvent({
|
|
551
|
+
type: 'complete',
|
|
552
|
+
moved: moved.length,
|
|
553
|
+
errors: errors.length,
|
|
554
|
+
errorMessages: errors,
|
|
555
|
+
})
|
|
556
|
+
} catch (error) {
|
|
557
|
+
console.error('Failed to move:', error)
|
|
558
|
+
sendEvent({ type: 'error', message: 'Failed to move items' })
|
|
559
|
+
} finally {
|
|
560
|
+
controller.close()
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
return new Response(stream, {
|
|
566
|
+
headers: {
|
|
567
|
+
'Content-Type': 'text/event-stream',
|
|
568
|
+
'Cache-Control': 'no-cache',
|
|
569
|
+
'Connection': 'keep-alive',
|
|
570
|
+
},
|
|
571
|
+
})
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
export async function handleMove(request: NextRequest) {
|
|
575
|
+
try {
|
|
576
|
+
const { paths, destination } = await request.json()
|
|
577
|
+
|
|
578
|
+
if (!paths || !Array.isArray(paths) || paths.length === 0) {
|
|
579
|
+
return NextResponse.json({ error: 'Paths are required' }, { status: 400 })
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (!destination || typeof destination !== 'string') {
|
|
583
|
+
return NextResponse.json({ error: 'Destination is required' }, { status: 400 })
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const safeDestination = destination.replace(/\.\./g, '')
|
|
587
|
+
const absoluteDestination = getWorkspacePath(safeDestination)
|
|
588
|
+
|
|
589
|
+
if (!absoluteDestination.startsWith(getPublicPath())) {
|
|
590
|
+
return NextResponse.json({ error: 'Invalid destination' }, { status: 400 })
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Ensure destination folder exists (create if needed)
|
|
594
|
+
await fs.mkdir(absoluteDestination, { recursive: true })
|
|
595
|
+
|
|
596
|
+
const moved: string[] = []
|
|
597
|
+
const errors: string[] = []
|
|
598
|
+
const meta = await loadMeta()
|
|
599
|
+
const cdnUrls = getCdnUrls(meta)
|
|
600
|
+
const r2PublicUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL?.replace(/\/$/, '') || ''
|
|
601
|
+
let metaChanged = false
|
|
602
|
+
|
|
603
|
+
for (const itemPath of paths) {
|
|
604
|
+
const safePath = itemPath.replace(/\.\./g, '')
|
|
605
|
+
const itemName = path.basename(safePath)
|
|
606
|
+
const newAbsolutePath = path.join(absoluteDestination, itemName)
|
|
607
|
+
|
|
608
|
+
// Build meta keys
|
|
609
|
+
const oldRelativePath = safePath.replace(/^public\//, '')
|
|
610
|
+
const newRelativePath = path.join(safeDestination.replace(/^public\//, ''), itemName)
|
|
611
|
+
const oldKey = '/' + oldRelativePath
|
|
612
|
+
const newKey = '/' + newRelativePath
|
|
613
|
+
|
|
614
|
+
// Check if destination already exists in meta
|
|
615
|
+
if (meta[newKey]) {
|
|
616
|
+
errors.push(`${itemName} already exists in destination`)
|
|
617
|
+
continue
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const entry = meta[oldKey] as MetaEntry | undefined
|
|
621
|
+
const isImage = isImageFile(itemName)
|
|
622
|
+
|
|
623
|
+
// Determine if cloud or remote
|
|
624
|
+
const isInCloud = entry?.c !== undefined
|
|
625
|
+
const fileCdnUrl = isInCloud && entry.c !== undefined ? cdnUrls[entry.c] : undefined
|
|
626
|
+
const isRemote = isInCloud && (!r2PublicUrl || fileCdnUrl !== r2PublicUrl)
|
|
627
|
+
const isPushedToR2 = isInCloud && r2PublicUrl && fileCdnUrl === r2PublicUrl
|
|
628
|
+
const hasProcessedThumbnails = isProcessed(entry)
|
|
629
|
+
|
|
630
|
+
try {
|
|
631
|
+
if (isRemote && isImage) {
|
|
632
|
+
// ===== REMOTE IMAGE: Download from external URL, save locally, remove c =====
|
|
633
|
+
const remoteUrl = `${fileCdnUrl}${oldKey}`
|
|
634
|
+
const buffer = await downloadFromRemoteUrl(remoteUrl)
|
|
635
|
+
|
|
636
|
+
// Save to new local location
|
|
637
|
+
await fs.mkdir(path.dirname(newAbsolutePath), { recursive: true })
|
|
638
|
+
await fs.writeFile(newAbsolutePath, buffer)
|
|
639
|
+
|
|
640
|
+
// Update meta: remove c (now local), keep other properties
|
|
641
|
+
const newEntry: MetaEntry = {
|
|
642
|
+
o: entry?.o,
|
|
643
|
+
b: entry?.b,
|
|
644
|
+
// Don't copy thumbnail dims since remote images don't have local thumbnails
|
|
645
|
+
// Don't copy c since it's now local
|
|
646
|
+
}
|
|
647
|
+
delete meta[oldKey]
|
|
648
|
+
meta[newKey] = newEntry
|
|
649
|
+
metaChanged = true
|
|
650
|
+
moved.push(itemPath)
|
|
651
|
+
|
|
652
|
+
} else if (isPushedToR2 && isImage) {
|
|
653
|
+
// ===== CLOUD IMAGE (R2): Download, move, re-upload, delete old =====
|
|
654
|
+
|
|
655
|
+
// Download original from R2
|
|
656
|
+
const buffer = await downloadFromCdn(oldKey)
|
|
657
|
+
|
|
658
|
+
// Save to new local location
|
|
659
|
+
await fs.mkdir(path.dirname(newAbsolutePath), { recursive: true })
|
|
660
|
+
await fs.writeFile(newAbsolutePath, buffer)
|
|
661
|
+
|
|
662
|
+
// Create new meta entry
|
|
663
|
+
let newEntry: MetaEntry = {
|
|
664
|
+
o: entry?.o,
|
|
665
|
+
b: entry?.b,
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// If processed, regenerate thumbnails
|
|
669
|
+
if (hasProcessedThumbnails) {
|
|
670
|
+
const processedEntry = await processImage(buffer, newKey)
|
|
671
|
+
newEntry = { ...newEntry, ...processedEntry }
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Upload original to new R2 location
|
|
675
|
+
await uploadOriginalToCdn(newKey)
|
|
676
|
+
|
|
677
|
+
// If processed, upload thumbnails to R2
|
|
678
|
+
if (hasProcessedThumbnails) {
|
|
679
|
+
await uploadToCdn(newKey)
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// Delete old files from R2
|
|
683
|
+
await deleteFromCdn(oldKey, hasProcessedThumbnails)
|
|
684
|
+
|
|
685
|
+
// Delete local files (keep cloud-only state)
|
|
686
|
+
try { await fs.unlink(newAbsolutePath) } catch { /* ignore */ }
|
|
687
|
+
if (hasProcessedThumbnails) {
|
|
688
|
+
await deleteLocalThumbnails(newKey)
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Set c to same CDN index
|
|
692
|
+
newEntry.c = entry?.c
|
|
693
|
+
|
|
694
|
+
// Update meta
|
|
695
|
+
delete meta[oldKey]
|
|
696
|
+
meta[newKey] = newEntry
|
|
697
|
+
metaChanged = true
|
|
698
|
+
moved.push(itemPath)
|
|
699
|
+
|
|
700
|
+
} else {
|
|
701
|
+
// ===== LOCAL FILE: Use standard fs.rename =====
|
|
702
|
+
const absolutePath = getWorkspacePath(safePath)
|
|
703
|
+
|
|
704
|
+
if (absoluteDestination.startsWith(absolutePath + path.sep)) {
|
|
705
|
+
errors.push(`Cannot move ${itemName} into itself`)
|
|
706
|
+
continue
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
try {
|
|
710
|
+
await fs.access(absolutePath)
|
|
711
|
+
} catch {
|
|
712
|
+
errors.push(`${itemName} not found`)
|
|
713
|
+
continue
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
try {
|
|
717
|
+
await fs.access(newAbsolutePath)
|
|
718
|
+
errors.push(`${itemName} already exists in destination`)
|
|
719
|
+
continue
|
|
720
|
+
} catch {
|
|
721
|
+
// Good - doesn't exist
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
await fs.rename(absolutePath, newAbsolutePath)
|
|
725
|
+
|
|
726
|
+
const stats = await fs.stat(newAbsolutePath)
|
|
727
|
+
if (stats.isFile() && isImage && entry) {
|
|
728
|
+
// Move local thumbnails
|
|
729
|
+
const oldThumbPaths = getAllThumbnailPaths(oldKey)
|
|
730
|
+
const newThumbPaths = getAllThumbnailPaths(newKey)
|
|
731
|
+
|
|
732
|
+
for (let i = 0; i < oldThumbPaths.length; i++) {
|
|
733
|
+
const oldThumbPath = getPublicPath( oldThumbPaths[i])
|
|
734
|
+
const newThumbPath = getPublicPath( newThumbPaths[i])
|
|
735
|
+
|
|
736
|
+
await fs.mkdir(path.dirname(newThumbPath), { recursive: true })
|
|
737
|
+
|
|
738
|
+
try {
|
|
739
|
+
await fs.rename(oldThumbPath, newThumbPath)
|
|
740
|
+
} catch {
|
|
741
|
+
// Thumbnail might not exist
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
delete meta[oldKey]
|
|
746
|
+
meta[newKey] = entry
|
|
747
|
+
metaChanged = true
|
|
748
|
+
} else if (stats.isDirectory()) {
|
|
749
|
+
// Move folder: update all meta entries under this folder
|
|
750
|
+
const oldPrefix = oldKey + '/'
|
|
751
|
+
const newPrefix = newKey + '/'
|
|
752
|
+
|
|
753
|
+
for (const key of Object.keys(meta)) {
|
|
754
|
+
if (key.startsWith(oldPrefix)) {
|
|
755
|
+
const newMetaKey = newPrefix + key.slice(oldPrefix.length)
|
|
756
|
+
meta[newMetaKey] = meta[key]
|
|
757
|
+
delete meta[key]
|
|
758
|
+
metaChanged = true
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
moved.push(itemPath)
|
|
764
|
+
}
|
|
765
|
+
} catch (err) {
|
|
766
|
+
console.error(`Failed to move ${itemName}:`, err)
|
|
767
|
+
errors.push(`Failed to move ${itemName}`)
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
if (metaChanged) {
|
|
772
|
+
await saveMeta(meta)
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
return NextResponse.json({
|
|
776
|
+
success: errors.length === 0,
|
|
777
|
+
moved,
|
|
778
|
+
errors: errors.length > 0 ? errors : undefined
|
|
779
|
+
})
|
|
780
|
+
} catch (error) {
|
|
781
|
+
console.error('Failed to move:', error)
|
|
782
|
+
return NextResponse.json({ error: 'Failed to move items' }, { status: 500 })
|
|
783
|
+
}
|
|
784
|
+
}
|