@gallop.software/studio 1.5.10 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/app/api/studio/[...path]/route.ts +1 -0
  2. package/app/layout.tsx +23 -0
  3. package/app/page.tsx +90 -0
  4. package/bin/studio.mjs +110 -0
  5. package/dist/handlers/index.js +77 -55
  6. package/dist/handlers/index.js.map +1 -1
  7. package/dist/handlers/index.mjs +128 -106
  8. package/dist/handlers/index.mjs.map +1 -1
  9. package/dist/index.d.mts +14 -10
  10. package/dist/index.d.ts +14 -10
  11. package/dist/index.js +2 -177
  12. package/dist/index.js.map +1 -1
  13. package/dist/index.mjs +4 -179
  14. package/dist/index.mjs.map +1 -1
  15. package/next.config.mjs +22 -0
  16. package/package.json +18 -10
  17. package/src/components/AddNewModal.tsx +402 -0
  18. package/src/components/ErrorModal.tsx +89 -0
  19. package/src/components/R2SetupModal.tsx +400 -0
  20. package/src/components/StudioBreadcrumb.tsx +115 -0
  21. package/src/components/StudioButton.tsx +200 -0
  22. package/src/components/StudioContext.tsx +219 -0
  23. package/src/components/StudioDetailView.tsx +714 -0
  24. package/src/components/StudioFileGrid.tsx +704 -0
  25. package/src/components/StudioFileList.tsx +743 -0
  26. package/src/components/StudioFolderPicker.tsx +342 -0
  27. package/src/components/StudioModal.tsx +473 -0
  28. package/src/components/StudioPreview.tsx +399 -0
  29. package/src/components/StudioSettings.tsx +536 -0
  30. package/src/components/StudioToolbar.tsx +1448 -0
  31. package/src/components/StudioUI.tsx +731 -0
  32. package/src/components/styles/common.ts +236 -0
  33. package/src/components/tokens.ts +78 -0
  34. package/src/components/useStudioActions.tsx +497 -0
  35. package/src/config/index.ts +7 -0
  36. package/src/config/workspace.ts +52 -0
  37. package/src/handlers/favicon.ts +152 -0
  38. package/src/handlers/files.ts +784 -0
  39. package/src/handlers/images.ts +949 -0
  40. package/src/handlers/import.ts +190 -0
  41. package/src/handlers/index.ts +168 -0
  42. package/src/handlers/list.ts +627 -0
  43. package/src/handlers/scan.ts +311 -0
  44. package/src/handlers/utils/cdn.ts +234 -0
  45. package/src/handlers/utils/files.ts +64 -0
  46. package/src/handlers/utils/index.ts +4 -0
  47. package/src/handlers/utils/meta.ts +102 -0
  48. package/src/handlers/utils/thumbnails.ts +98 -0
  49. package/src/hooks/useFileList.ts +143 -0
  50. package/src/index.tsx +36 -0
  51. package/src/lib/api.ts +176 -0
  52. package/src/types.ts +119 -0
  53. package/dist/StudioUI-GJK45R3T.js +0 -6500
  54. package/dist/StudioUI-GJK45R3T.js.map +0 -1
  55. package/dist/StudioUI-QZ54STXE.mjs +0 -6500
  56. package/dist/StudioUI-QZ54STXE.mjs.map +0 -1
  57. package/dist/chunk-N6JYTJCB.js +0 -68
  58. package/dist/chunk-N6JYTJCB.js.map +0 -1
  59. package/dist/chunk-RHI3UROE.mjs +0 -68
  60. package/dist/chunk-RHI3UROE.mjs.map +0 -1
@@ -0,0 +1,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
+ }