@gallop.software/studio 1.5.10 → 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.
Files changed (60) hide show
  1. package/app/api/studio/[...path]/route.ts +1 -0
  2. package/app/layout.tsx +20 -0
  3. package/app/page.tsx +82 -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,627 @@
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+ import { promises as fs } from 'fs'
3
+ import path from 'path'
4
+ import type { FileItem, MetaEntry } from '../types'
5
+ import { loadMeta, isImageFile, getCdnUrls, getFileEntries } from './utils'
6
+ import { getThumbnailPath, isProcessed } from '../types'
7
+ import { getPublicPath, getWorkspacePath } from '../config'
8
+
9
+ /**
10
+ * Get all thumbnail file info for a processed meta entry
11
+ * Returns the thumbnail paths that exist based on which dimension properties are present
12
+ */
13
+ function getExistingThumbnails(originalPath: string, entry: MetaEntry): Array<{ path: string; size: 'f' | 'lg' | 'md' | 'sm' }> {
14
+ const thumbnails: Array<{ path: string; size: 'f' | 'lg' | 'md' | 'sm' }> = []
15
+
16
+ if (entry.f) {
17
+ thumbnails.push({ path: getThumbnailPath(originalPath, 'full'), size: 'f' })
18
+ }
19
+ if (entry.lg) {
20
+ thumbnails.push({ path: getThumbnailPath(originalPath, 'lg'), size: 'lg' })
21
+ }
22
+ if (entry.md) {
23
+ thumbnails.push({ path: getThumbnailPath(originalPath, 'md'), size: 'md' })
24
+ }
25
+ if (entry.sm) {
26
+ thumbnails.push({ path: getThumbnailPath(originalPath, 'sm'), size: 'sm' })
27
+ }
28
+
29
+ return thumbnails
30
+ }
31
+
32
+ /**
33
+ * Count cloud, remote, and local files for a folder prefix
34
+ */
35
+ function countFileTypes(
36
+ folderPrefix: string,
37
+ fileEntries: [string, MetaEntry][],
38
+ cdnUrls: string[],
39
+ r2PublicUrl: string
40
+ ): { cloudCount: number; remoteCount: number; localCount: number } {
41
+ let cloudCount = 0
42
+ let remoteCount = 0
43
+ let localCount = 0
44
+
45
+ for (const [key, entry] of fileEntries) {
46
+ if (key.startsWith(folderPrefix)) {
47
+ if (entry.c !== undefined) {
48
+ // Check if it's our R2 or a remote URL
49
+ const cdnUrl = cdnUrls[entry.c]
50
+ if (cdnUrl === r2PublicUrl) {
51
+ cloudCount++
52
+ } else {
53
+ remoteCount++
54
+ }
55
+ } else {
56
+ localCount++
57
+ }
58
+ }
59
+ }
60
+
61
+ return { cloudCount, remoteCount, localCount }
62
+ }
63
+
64
+ /**
65
+ * List files and folders from meta
66
+ * Folders are derived from file paths in meta AND filesystem
67
+ */
68
+ export async function handleList(request: NextRequest) {
69
+ const searchParams = request.nextUrl.searchParams
70
+ const requestedPath = searchParams.get('path') || 'public'
71
+
72
+ try {
73
+ const meta = await loadMeta()
74
+ const fileEntries = getFileEntries(meta)
75
+ const cdnUrls = getCdnUrls(meta)
76
+ const r2PublicUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL?.replace(/\/$/, '') || ''
77
+
78
+ // Normalize the requested path to match meta keys
79
+ // requestedPath is like "public" or "public/photos"
80
+ // meta keys are like "/photos/image.jpg"
81
+ const relativePath = requestedPath.replace(/^public\/?/, '')
82
+ const pathPrefix = relativePath ? `/${relativePath}/` : '/'
83
+
84
+ const items: FileItem[] = []
85
+ const seenFolders = new Set<string>()
86
+ const metaKeys = fileEntries.map(([key]) => key)
87
+
88
+ // Check if we're inside the images folder (protected area)
89
+ const isInsideImagesFolder = relativePath === 'images' || relativePath.startsWith('images/')
90
+
91
+ // For the images folder, derive contents from meta entries with thumbnails
92
+ if (isInsideImagesFolder) {
93
+ // Get the path within images folder (e.g., "images/subfolder" -> "subfolder")
94
+ const imagesSubPath = relativePath.replace(/^images\/?/, '')
95
+ const imagesPrefix = imagesSubPath ? `/${imagesSubPath}/` : '/'
96
+
97
+ // Collect all thumbnails from processed entries
98
+ const allThumbnails: Array<{ path: string; size: 'f' | 'lg' | 'md' | 'sm'; originalKey: string }> = []
99
+
100
+ for (const [key, entry] of fileEntries) {
101
+ if (isProcessed(entry)) {
102
+ const thumbnails = getExistingThumbnails(key, entry)
103
+ for (const thumb of thumbnails) {
104
+ allThumbnails.push({ ...thumb, originalKey: key })
105
+ }
106
+ }
107
+ }
108
+
109
+ // Filter thumbnails that are in the current images subfolder
110
+ for (const thumb of allThumbnails) {
111
+ // thumb.path is like "/images/photos/image.jpg" or "/images/photos/image-lg.jpg"
112
+ // We need to check if it's under the current images path
113
+ const thumbRelative = thumb.path.replace(/^\/images\/?/, '')
114
+
115
+ // Get the original entry to check if it's on CDN
116
+ const originalEntry = fileEntries.find(([k]) => k === thumb.originalKey)?.[1]
117
+ const cdnIndex = originalEntry?.c
118
+ const cdnBaseUrl = cdnIndex !== undefined ? cdnUrls[cdnIndex] : undefined
119
+ // Build the full thumbnail URL (with CDN base if applicable)
120
+ const thumbnailUrl = cdnBaseUrl ? `${cdnBaseUrl}${thumb.path}` : thumb.path
121
+ // Determine if it's pushed to CDN and if it's remote (not our R2)
122
+ const isPushedToCloud = cdnIndex !== undefined
123
+ const isRemote = isPushedToCloud && cdnBaseUrl !== r2PublicUrl
124
+
125
+ // Get dimensions for this thumbnail size
126
+ const thumbDims = originalEntry?.[thumb.size]
127
+ const dimensions = thumbDims ? { width: thumbDims.w, height: thumbDims.h } : undefined
128
+
129
+ // Check if this is directly in the current folder or in a subfolder
130
+ if (imagesSubPath === '') {
131
+ // We're at /images root
132
+ const slashIndex = thumbRelative.indexOf('/')
133
+ if (slashIndex === -1) {
134
+ // Direct file in images root
135
+ const fileName = thumbRelative
136
+ items.push({
137
+ name: fileName,
138
+ path: `public/images/${fileName}`,
139
+ type: 'file',
140
+ thumbnail: thumbnailUrl,
141
+ hasThumbnail: false,
142
+ isProtected: true,
143
+ cdnPushed: isPushedToCloud,
144
+ cdnBaseUrl,
145
+ isRemote,
146
+ dimensions,
147
+ })
148
+ } else {
149
+ // In a subfolder - add the folder
150
+ const folderName = thumbRelative.slice(0, slashIndex)
151
+ if (!seenFolders.has(folderName)) {
152
+ seenFolders.add(folderName)
153
+ // Count thumbnails in this folder
154
+ const folderPrefix = `/${folderName}/`
155
+ const fileCount = allThumbnails.filter(t =>
156
+ t.path.replace(/^\/images/, '').startsWith(folderPrefix)
157
+ ).length
158
+ items.push({
159
+ name: folderName,
160
+ path: `public/images/${folderName}`,
161
+ type: 'folder',
162
+ fileCount,
163
+ isProtected: true,
164
+ })
165
+ }
166
+ }
167
+ } else {
168
+ // We're in a subfolder of images
169
+ if (!thumbRelative.startsWith(imagesSubPath + '/') && thumbRelative !== imagesSubPath) continue
170
+
171
+ const remaining = thumbRelative.slice(imagesSubPath.length + 1)
172
+ if (!remaining) continue
173
+
174
+ const slashIndex = remaining.indexOf('/')
175
+ if (slashIndex === -1) {
176
+ // Direct file
177
+ items.push({
178
+ name: remaining,
179
+ path: `public/images/${imagesSubPath}/${remaining}`,
180
+ type: 'file',
181
+ thumbnail: thumbnailUrl,
182
+ hasThumbnail: false,
183
+ isProtected: true,
184
+ cdnPushed: isPushedToCloud,
185
+ cdnBaseUrl,
186
+ isRemote,
187
+ dimensions,
188
+ })
189
+ } else {
190
+ // Subfolder
191
+ const folderName = remaining.slice(0, slashIndex)
192
+ if (!seenFolders.has(folderName)) {
193
+ seenFolders.add(folderName)
194
+ const folderPrefix = `${imagesSubPath}/${folderName}/`
195
+ const fileCount = allThumbnails.filter(t =>
196
+ t.path.replace(/^\/images\//, '').startsWith(folderPrefix)
197
+ ).length
198
+ items.push({
199
+ name: folderName,
200
+ path: `public/images/${imagesSubPath}/${folderName}`,
201
+ type: 'folder',
202
+ fileCount,
203
+ isProtected: true,
204
+ })
205
+ }
206
+ }
207
+ }
208
+ }
209
+
210
+ return NextResponse.json({ items })
211
+ }
212
+
213
+ // Not in images folder - check filesystem for folders (including empty ones)
214
+ const absoluteDir = getWorkspacePath(requestedPath)
215
+ try {
216
+ const dirEntries = await fs.readdir(absoluteDir, { withFileTypes: true })
217
+ for (const entry of dirEntries) {
218
+ if (entry.name.startsWith('.')) continue
219
+
220
+ if (entry.isDirectory()) {
221
+ if (!seenFolders.has(entry.name)) {
222
+ seenFolders.add(entry.name)
223
+
224
+ // Check if this folder is the images folder
225
+ const isImagesFolder = entry.name === 'images' && !relativePath
226
+ const folderPath = relativePath ? `public/${relativePath}/${entry.name}` : `public/${entry.name}`
227
+
228
+ // Count files in this folder
229
+ let fileCount = 0
230
+ let cloudCount = 0
231
+ let remoteCount = 0
232
+ let localCount = 0
233
+
234
+ if (isImagesFolder) {
235
+ // Count thumbnails from meta for images folder
236
+ for (const [key, metaEntry] of fileEntries) {
237
+ if (isProcessed(metaEntry)) {
238
+ fileCount += getExistingThumbnails(key, metaEntry).length
239
+ }
240
+ }
241
+ } else {
242
+ // Count files from meta for regular folders
243
+ const folderPrefix = pathPrefix === '/' ? `/${entry.name}/` : `${pathPrefix}${entry.name}/`
244
+ for (const k of metaKeys) {
245
+ if (k.startsWith(folderPrefix)) fileCount++
246
+ }
247
+ // Count cloud vs remote vs local
248
+ const counts = countFileTypes(folderPrefix, fileEntries, cdnUrls, r2PublicUrl)
249
+ cloudCount = counts.cloudCount
250
+ remoteCount = counts.remoteCount
251
+ localCount = counts.localCount
252
+ }
253
+
254
+ items.push({
255
+ name: entry.name,
256
+ path: folderPath,
257
+ type: 'folder',
258
+ fileCount,
259
+ cloudCount,
260
+ remoteCount,
261
+ localCount,
262
+ isProtected: isImagesFolder,
263
+ })
264
+ }
265
+ }
266
+ }
267
+ } catch {
268
+ // Directory might not exist (all files in cloud)
269
+ }
270
+
271
+ // Always show images folder at root level if any processed images exist
272
+ if (!relativePath && !seenFolders.has('images')) {
273
+ let thumbnailCount = 0
274
+ for (const [key, entry] of fileEntries) {
275
+ if (isProcessed(entry)) {
276
+ thumbnailCount += getExistingThumbnails(key, entry).length
277
+ }
278
+ }
279
+ if (thumbnailCount > 0) {
280
+ items.push({
281
+ name: 'images',
282
+ path: 'public/images',
283
+ type: 'folder',
284
+ fileCount: thumbnailCount,
285
+ isProtected: true,
286
+ })
287
+ }
288
+ }
289
+
290
+ // If meta is empty and no folders found, return empty with a flag
291
+ if (fileEntries.length === 0 && items.length === 0) {
292
+ return NextResponse.json({ items: [], isEmpty: true })
293
+ }
294
+
295
+ for (const [key, entry] of fileEntries) {
296
+ // Check if this file is under the current path
297
+ if (!key.startsWith(pathPrefix) && pathPrefix !== '/') continue
298
+ if (pathPrefix === '/' && !key.startsWith('/')) continue
299
+
300
+ // Get the part after the current path
301
+ const remaining = pathPrefix === '/' ? key.slice(1) : key.slice(pathPrefix.length)
302
+
303
+ // Skip if empty (shouldn't happen)
304
+ if (!remaining) continue
305
+
306
+ // Check if there's a subfolder
307
+ const slashIndex = remaining.indexOf('/')
308
+
309
+ if (slashIndex !== -1) {
310
+ // This is in a subfolder - show the folder
311
+ const folderName = remaining.slice(0, slashIndex)
312
+
313
+ if (!seenFolders.has(folderName)) {
314
+ seenFolders.add(folderName)
315
+
316
+ // Count files in this folder from meta
317
+ const folderPrefix = pathPrefix === '/' ? `/${folderName}/` : `${pathPrefix}${folderName}/`
318
+ let fileCount = 0
319
+ for (const k of metaKeys) {
320
+ if (k.startsWith(folderPrefix)) fileCount++
321
+ }
322
+
323
+ // Count cloud vs remote vs local
324
+ const counts = countFileTypes(folderPrefix, fileEntries, cdnUrls, r2PublicUrl)
325
+
326
+ items.push({
327
+ name: folderName,
328
+ path: relativePath ? `public/${relativePath}/${folderName}` : `public/${folderName}`,
329
+ type: 'folder',
330
+ fileCount,
331
+ cloudCount: counts.cloudCount,
332
+ remoteCount: counts.remoteCount,
333
+ localCount: counts.localCount,
334
+ isProtected: isInsideImagesFolder,
335
+ })
336
+ }
337
+ } else {
338
+ // This is a file in the current folder
339
+ const fileName = remaining
340
+ const isImage = isImageFile(fileName)
341
+ const isPushedToCloud = entry.c !== undefined
342
+
343
+ // Determine if this is a remote import vs pushed to our R2
344
+ const fileCdnUrl = isPushedToCloud && entry.c !== undefined ? cdnUrls[entry.c] : undefined
345
+ const isRemote = isPushedToCloud && (!r2PublicUrl || fileCdnUrl !== r2PublicUrl)
346
+
347
+ let thumbnail: string | undefined
348
+ let hasThumbnail = false
349
+ let fileSize: number | undefined
350
+
351
+ const entryIsProcessed = isProcessed(entry)
352
+
353
+ if (isImage && entryIsProcessed) {
354
+ // Has been processed - use thumbnail
355
+ const thumbPath = getThumbnailPath(key, 'sm')
356
+
357
+ if (isPushedToCloud && entry.c !== undefined) {
358
+ // CDN thumbnail - get URL from _cdns array
359
+ const cdnUrl = cdnUrls[entry.c]
360
+ if (cdnUrl) {
361
+ thumbnail = `${cdnUrl}${thumbPath}`
362
+ hasThumbnail = true
363
+ }
364
+ } else {
365
+ // Local thumbnail - check if exists
366
+ const localThumbPath = getPublicPath(thumbPath)
367
+ try {
368
+ await fs.access(localThumbPath)
369
+ thumbnail = thumbPath
370
+ hasThumbnail = true
371
+ } catch {
372
+ // Thumbnail doesn't exist yet
373
+ thumbnail = key
374
+ hasThumbnail = false
375
+ }
376
+ }
377
+ } else if (isImage) {
378
+ // Not processed yet - use original (from CDN if available)
379
+ if (isPushedToCloud && entry.c !== undefined) {
380
+ const cdnUrl = cdnUrls[entry.c]
381
+ thumbnail = cdnUrl ? `${cdnUrl}${key}` : key
382
+ } else {
383
+ thumbnail = key
384
+ }
385
+ hasThumbnail = false
386
+ }
387
+
388
+ // Try to get file size if file exists locally
389
+ if (!isPushedToCloud) {
390
+ try {
391
+ const filePath = getPublicPath(key)
392
+ const stats = await fs.stat(filePath)
393
+ fileSize = stats.size
394
+ } catch {
395
+ // File might not exist locally (synced)
396
+ }
397
+ }
398
+
399
+ items.push({
400
+ name: fileName,
401
+ path: relativePath ? `public/${relativePath}/${fileName}` : `public/${fileName}`,
402
+ type: 'file',
403
+ size: fileSize,
404
+ thumbnail,
405
+ hasThumbnail,
406
+ isProcessed: entryIsProcessed,
407
+ cdnPushed: isPushedToCloud,
408
+ cdnBaseUrl: fileCdnUrl,
409
+ isRemote,
410
+ isProtected: isInsideImagesFolder,
411
+ dimensions: entry.o ? { width: entry.o.w, height: entry.o.h } : undefined,
412
+ })
413
+ }
414
+ }
415
+
416
+ return NextResponse.json({ items })
417
+ } catch (error) {
418
+ console.error('Failed to list directory:', error)
419
+ return NextResponse.json({ error: 'Failed to list directory' }, { status: 500 })
420
+ }
421
+ }
422
+
423
+ export async function handleSearch(request: NextRequest) {
424
+ const searchParams = request.nextUrl.searchParams
425
+ const query = searchParams.get('q')?.toLowerCase() || ''
426
+
427
+ if (query.length < 2) {
428
+ return NextResponse.json({ items: [] })
429
+ }
430
+
431
+ try {
432
+ const meta = await loadMeta()
433
+ const fileEntries = getFileEntries(meta)
434
+ const cdnUrls = getCdnUrls(meta)
435
+ const r2PublicUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL?.replace(/\/$/, '') || ''
436
+ const items: FileItem[] = []
437
+
438
+ for (const [key, entry] of fileEntries) {
439
+ // Check if the path matches the query
440
+ if (!key.toLowerCase().includes(query)) continue
441
+
442
+ const fileName = path.basename(key)
443
+ const relativePath = key.slice(1) // Remove leading /
444
+ const isImage = isImageFile(fileName)
445
+ const isPushedToCloud = entry.c !== undefined
446
+
447
+ // Determine if this is a remote import vs pushed to our R2
448
+ const fileCdnUrl = isPushedToCloud && entry.c !== undefined ? cdnUrls[entry.c] : undefined
449
+ const isRemote = isPushedToCloud && (!r2PublicUrl || fileCdnUrl !== r2PublicUrl)
450
+
451
+ let thumbnail: string | undefined
452
+ let hasThumbnail = false
453
+ const entryIsProcessed = isProcessed(entry)
454
+
455
+ if (isImage && entryIsProcessed) {
456
+ // Has been processed - use thumbnail
457
+ const thumbPath = getThumbnailPath(key, 'sm')
458
+
459
+ if (isPushedToCloud && entry.c !== undefined) {
460
+ const cdnUrl = cdnUrls[entry.c]
461
+ if (cdnUrl) {
462
+ thumbnail = `${cdnUrl}${thumbPath}`
463
+ hasThumbnail = true
464
+ }
465
+ } else {
466
+ const localThumbPath = getPublicPath(thumbPath)
467
+ try {
468
+ await fs.access(localThumbPath)
469
+ thumbnail = thumbPath
470
+ hasThumbnail = true
471
+ } catch {
472
+ thumbnail = key
473
+ hasThumbnail = false
474
+ }
475
+ }
476
+ } else if (isImage) {
477
+ // Not processed yet - use original (from CDN if available)
478
+ if (isPushedToCloud && entry.c !== undefined) {
479
+ const cdnUrl = cdnUrls[entry.c]
480
+ thumbnail = cdnUrl ? `${cdnUrl}${key}` : key
481
+ } else {
482
+ thumbnail = key
483
+ }
484
+ hasThumbnail = false
485
+ }
486
+
487
+ items.push({
488
+ name: fileName,
489
+ path: `public/${relativePath}`,
490
+ type: 'file',
491
+ thumbnail,
492
+ hasThumbnail,
493
+ isProcessed: entryIsProcessed,
494
+ cdnPushed: isPushedToCloud,
495
+ cdnBaseUrl: fileCdnUrl,
496
+ isRemote,
497
+ dimensions: entry.o ? { width: entry.o.w, height: entry.o.h } : undefined,
498
+ })
499
+ }
500
+
501
+ return NextResponse.json({ items })
502
+ } catch (error) {
503
+ console.error('Failed to search:', error)
504
+ return NextResponse.json({ error: 'Failed to search' }, { status: 500 })
505
+ }
506
+ }
507
+
508
+ export async function handleListFolders() {
509
+ try {
510
+ const meta = await loadMeta()
511
+ const fileEntries = getFileEntries(meta)
512
+ const folderSet = new Set<string>()
513
+
514
+ // Extract all folder paths from meta keys
515
+ for (const [key] of fileEntries) {
516
+ const parts = key.split('/')
517
+ // Build up folder paths: /photos/2024/image.jpg -> photos, photos/2024
518
+ let current = ''
519
+ for (let i = 1; i < parts.length - 1; i++) {
520
+ current = current ? `${current}/${parts[i]}` : parts[i]
521
+ folderSet.add(current)
522
+ }
523
+ }
524
+
525
+ // Also scan filesystem recursively for folders (including empty ones)
526
+ async function scanDir(dir: string, relativePath: string): Promise<void> {
527
+ try {
528
+ const entries = await fs.readdir(dir, { withFileTypes: true })
529
+ for (const entry of entries) {
530
+ if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'images') {
531
+ const folderRelPath = relativePath ? `${relativePath}/${entry.name}` : entry.name
532
+ folderSet.add(folderRelPath)
533
+ // Recurse into subdirectory
534
+ await scanDir(path.join(dir, entry.name), folderRelPath)
535
+ }
536
+ }
537
+ } catch {
538
+ // Directory might not exist
539
+ }
540
+ }
541
+
542
+ const publicDir = getPublicPath()
543
+ await scanDir(publicDir, '')
544
+
545
+ const folders: { path: string; name: string; depth: number }[] = []
546
+ folders.push({ path: 'public', name: 'public', depth: 0 })
547
+
548
+ const sortedFolders = Array.from(folderSet).sort()
549
+ for (const folderPath of sortedFolders) {
550
+ const depth = folderPath.split('/').length
551
+ const name = folderPath.split('/').pop() || folderPath
552
+ folders.push({
553
+ path: `public/${folderPath}`,
554
+ name,
555
+ depth
556
+ })
557
+ }
558
+
559
+ return NextResponse.json({ folders })
560
+ } catch (error) {
561
+ console.error('Failed to list folders:', error)
562
+ return NextResponse.json({ error: 'Failed to list folders' }, { status: 500 })
563
+ }
564
+ }
565
+
566
+ export async function handleCountImages() {
567
+ try {
568
+ const meta = await loadMeta()
569
+ const fileEntries = getFileEntries(meta)
570
+ const allImages: string[] = []
571
+
572
+ for (const [key] of fileEntries) {
573
+ const fileName = path.basename(key)
574
+ if (isImageFile(fileName)) {
575
+ allImages.push(key.slice(1)) // Remove leading /
576
+ }
577
+ }
578
+
579
+ return NextResponse.json({
580
+ count: allImages.length,
581
+ images: allImages,
582
+ })
583
+ } catch (error) {
584
+ console.error('Failed to count images:', error)
585
+ return NextResponse.json({ error: 'Failed to count images' }, { status: 500 })
586
+ }
587
+ }
588
+
589
+ export async function handleFolderImages(request: NextRequest) {
590
+ try {
591
+ const searchParams = request.nextUrl.searchParams
592
+ const foldersParam = searchParams.get('folders')
593
+
594
+ if (!foldersParam) {
595
+ return NextResponse.json({ error: 'No folders provided' }, { status: 400 })
596
+ }
597
+
598
+ const folders = foldersParam.split(',')
599
+ const meta = await loadMeta()
600
+ const fileEntries = getFileEntries(meta)
601
+ const allFiles: string[] = []
602
+
603
+ // Convert folder paths to prefixes for matching
604
+ const prefixes = folders.map(f => {
605
+ const rel = f.replace(/^public\/?/, '')
606
+ return rel ? `/${rel}/` : '/'
607
+ })
608
+
609
+ for (const [key] of fileEntries) {
610
+ // Check if this file is in one of the requested folders
611
+ for (const prefix of prefixes) {
612
+ if (key.startsWith(prefix) || (prefix === '/' && key.startsWith('/'))) {
613
+ allFiles.push(key.slice(1)) // Remove leading /
614
+ break
615
+ }
616
+ }
617
+ }
618
+
619
+ return NextResponse.json({
620
+ count: allFiles.length,
621
+ images: allFiles, // Keep as 'images' for backwards compatibility
622
+ })
623
+ } catch (error) {
624
+ console.error('Failed to get folder files:', error)
625
+ return NextResponse.json({ error: 'Failed to get folder files' }, { status: 500 })
626
+ }
627
+ }