@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.
- 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 +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,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
|
+
}
|