@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,311 @@
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 { encode } from 'blurhash'
6
+ import { loadMeta, saveMeta, isMediaFile, isImageFile, getFileEntries } from './utils'
7
+ import { getAllThumbnailPaths, isProcessed } from '../types'
8
+ import { getPublicPath } from '../config'
9
+
10
+ /**
11
+ * Streaming scan handler - scans filesystem for new files not in meta
12
+ * For images, reads dimensions (w/h)
13
+ * Handles collisions by renaming files with -1, -2, etc.
14
+ * Also detects orphaned files in the images folder
15
+ */
16
+ export async function handleScanStream() {
17
+ const encoder = new TextEncoder()
18
+
19
+ const stream = new ReadableStream({
20
+ async start(controller) {
21
+ const sendEvent = (data: object) => {
22
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
23
+ }
24
+
25
+ try {
26
+ const meta = await loadMeta()
27
+ const existingCount = Object.keys(meta).filter(k => !k.startsWith('_')).length
28
+ const existingKeys = new Set(Object.keys(meta))
29
+ const added: string[] = []
30
+ const renamed: Array<{ from: string; to: string }> = []
31
+ const errors: string[] = []
32
+ const orphanedFiles: string[] = []
33
+
34
+ // Collect all files first
35
+ const allFiles: Array<{ relativePath: string; fullPath: string }> = []
36
+
37
+ async function scanDir(dir: string, relativePath: string = ''): Promise<void> {
38
+ try {
39
+ const entries = await fs.readdir(dir, { withFileTypes: true })
40
+
41
+ for (const entry of entries) {
42
+ if (entry.name.startsWith('.')) continue
43
+
44
+ const fullPath = path.join(dir, entry.name)
45
+ const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name
46
+
47
+ // Skip the images folder (generated thumbnails)
48
+ if (relPath === 'images' || relPath.startsWith('images/')) continue
49
+
50
+ if (entry.isDirectory()) {
51
+ await scanDir(fullPath, relPath)
52
+ } else if (isMediaFile(entry.name)) {
53
+ allFiles.push({ relativePath: relPath, fullPath })
54
+ }
55
+ }
56
+ } catch {
57
+ // Directory might not exist
58
+ }
59
+ }
60
+
61
+ const publicDir = getPublicPath()
62
+ await scanDir(publicDir)
63
+
64
+ const total = allFiles.length
65
+ sendEvent({ type: 'start', total })
66
+
67
+ for (let i = 0; i < allFiles.length; i++) {
68
+ let { relativePath, fullPath } = allFiles[i]
69
+ let imageKey = '/' + relativePath
70
+
71
+ sendEvent({
72
+ type: 'progress',
73
+ current: i + 1,
74
+ total,
75
+ percent: Math.round(((i + 1) / total) * 100),
76
+ currentFile: relativePath
77
+ })
78
+
79
+ // Check if already in meta
80
+ if (existingKeys.has(imageKey)) {
81
+ // File already tracked - skip
82
+ continue
83
+ }
84
+
85
+ // Check for collision (path exists in meta but file is new)
86
+ if (meta[imageKey]) {
87
+ // Need to rename this file to avoid collision
88
+ const ext = path.extname(relativePath)
89
+ const baseName = relativePath.slice(0, -ext.length)
90
+ let counter = 1
91
+ let newKey = `/${baseName}-${counter}${ext}`
92
+
93
+ while (meta[newKey]) {
94
+ counter++
95
+ newKey = `/${baseName}-${counter}${ext}`
96
+ }
97
+
98
+ // Rename the physical file
99
+ const newRelativePath = `${baseName}-${counter}${ext}`
100
+ const newFullPath = getPublicPath(newRelativePath)
101
+
102
+ try {
103
+ await fs.rename(fullPath, newFullPath)
104
+ renamed.push({ from: relativePath, to: newRelativePath })
105
+ relativePath = newRelativePath
106
+ fullPath = newFullPath
107
+ imageKey = newKey
108
+ } catch (err) {
109
+ console.error(`Failed to rename ${relativePath}:`, err)
110
+ errors.push(`Failed to rename ${relativePath}`)
111
+ continue
112
+ }
113
+ }
114
+
115
+ try {
116
+ const isImage = isImageFile(relativePath)
117
+
118
+ if (isImage) {
119
+ // Read dimensions and generate blurhash for images
120
+ const ext = path.extname(relativePath).toLowerCase()
121
+
122
+ if (ext === '.svg') {
123
+ // SVGs don't have pixel dimensions in the same way
124
+ meta[imageKey] = { o: { w: 0, h: 0 }, b: '' }
125
+ } else {
126
+ try {
127
+ const buffer = await fs.readFile(fullPath)
128
+ const metadata = await sharp(buffer).metadata()
129
+
130
+ // Generate blurhash
131
+ const { data, info } = await sharp(buffer)
132
+ .resize(32, 32, { fit: 'inside' })
133
+ .ensureAlpha()
134
+ .raw()
135
+ .toBuffer({ resolveWithObject: true })
136
+
137
+ const blurhash = encode(new Uint8ClampedArray(data), info.width, info.height, 4, 4)
138
+
139
+ meta[imageKey] = {
140
+ o: { w: metadata.width || 0, h: metadata.height || 0 },
141
+ b: blurhash,
142
+ }
143
+ } catch {
144
+ // Couldn't read dimensions
145
+ meta[imageKey] = { o: { w: 0, h: 0 } }
146
+ }
147
+ }
148
+ } else {
149
+ // Non-image files - just add empty entry
150
+ meta[imageKey] = {}
151
+ }
152
+
153
+ existingKeys.add(imageKey)
154
+ added.push(imageKey)
155
+ } catch (error) {
156
+ console.error(`Failed to process ${relativePath}:`, error)
157
+ errors.push(relativePath)
158
+ }
159
+ }
160
+
161
+ // Check for orphaned files in the images folder
162
+ sendEvent({ type: 'cleanup', message: 'Checking for orphaned thumbnails...' })
163
+
164
+ // Build set of expected thumbnail paths from meta entries
165
+ const expectedThumbnails = new Set<string>()
166
+ const fileEntries = getFileEntries(meta)
167
+ for (const [imageKey, entry] of fileEntries) {
168
+ // Only track local thumbnails (not pushed to CDN)
169
+ if (entry.c === undefined && isProcessed(entry)) {
170
+ for (const thumbPath of getAllThumbnailPaths(imageKey)) {
171
+ expectedThumbnails.add(thumbPath)
172
+ }
173
+ }
174
+ }
175
+
176
+ // Scan the images folder for orphaned files
177
+ async function findOrphans(dir: string, relativePath: string = ''): Promise<void> {
178
+ try {
179
+ const entries = await fs.readdir(dir, { withFileTypes: true })
180
+
181
+ for (const entry of entries) {
182
+ if (entry.name.startsWith('.')) continue
183
+
184
+ const fullPath = path.join(dir, entry.name)
185
+ const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name
186
+
187
+ if (entry.isDirectory()) {
188
+ await findOrphans(fullPath, relPath)
189
+ } else if (isImageFile(entry.name)) {
190
+ const publicPath = `/images/${relPath}`
191
+ if (!expectedThumbnails.has(publicPath)) {
192
+ orphanedFiles.push(publicPath)
193
+ }
194
+ }
195
+ }
196
+ } catch {
197
+ // Directory might not exist
198
+ }
199
+ }
200
+
201
+ const imagesDir = getPublicPath('images')
202
+ try {
203
+ await findOrphans(imagesDir)
204
+ } catch {
205
+ // images dir might not exist
206
+ }
207
+
208
+ await saveMeta(meta)
209
+
210
+ sendEvent({
211
+ type: 'complete',
212
+ existingCount,
213
+ added: added.length,
214
+ renamed: renamed.length,
215
+ errors: errors.length,
216
+ renamedFiles: renamed,
217
+ orphanedFiles: orphanedFiles.length > 0 ? orphanedFiles : undefined,
218
+ })
219
+ } catch (error) {
220
+ console.error('Scan failed:', error)
221
+ sendEvent({ type: 'error', message: 'Scan failed' })
222
+ } finally {
223
+ controller.close()
224
+ }
225
+ }
226
+ })
227
+
228
+ return new Response(stream, {
229
+ headers: {
230
+ 'Content-Type': 'text/event-stream',
231
+ 'Cache-Control': 'no-cache',
232
+ 'Connection': 'keep-alive',
233
+ },
234
+ })
235
+ }
236
+
237
+ /**
238
+ * Delete orphaned files from the images folder
239
+ */
240
+ export async function handleDeleteOrphans(request: NextRequest) {
241
+ try {
242
+ const { paths } = await request.json() as { paths: string[] }
243
+
244
+ if (!paths || !Array.isArray(paths) || paths.length === 0) {
245
+ return NextResponse.json({ error: 'No paths provided' }, { status: 400 })
246
+ }
247
+
248
+ const deleted: string[] = []
249
+ const errors: string[] = []
250
+
251
+ for (const orphanPath of paths) {
252
+ // Ensure the path is within the images folder for safety
253
+ if (!orphanPath.startsWith('/images/')) {
254
+ errors.push(`Invalid path: ${orphanPath}`)
255
+ continue
256
+ }
257
+
258
+ const fullPath = getPublicPath(orphanPath)
259
+
260
+ try {
261
+ await fs.unlink(fullPath)
262
+ deleted.push(orphanPath)
263
+ } catch (err) {
264
+ console.error(`Failed to delete ${orphanPath}:`, err)
265
+ errors.push(orphanPath)
266
+ }
267
+ }
268
+
269
+ // Clean up empty directories
270
+ const imagesDir = getPublicPath('images')
271
+
272
+ async function removeEmptyDirs(dir: string): Promise<boolean> {
273
+ try {
274
+ const entries = await fs.readdir(dir, { withFileTypes: true })
275
+ let isEmpty = true
276
+
277
+ for (const entry of entries) {
278
+ if (entry.isDirectory()) {
279
+ const subDirEmpty = await removeEmptyDirs(path.join(dir, entry.name))
280
+ if (!subDirEmpty) isEmpty = false
281
+ } else {
282
+ isEmpty = false
283
+ }
284
+ }
285
+
286
+ if (isEmpty && dir !== imagesDir) {
287
+ await fs.rmdir(dir)
288
+ }
289
+
290
+ return isEmpty
291
+ } catch {
292
+ return true
293
+ }
294
+ }
295
+
296
+ try {
297
+ await removeEmptyDirs(imagesDir)
298
+ } catch {
299
+ // images dir might not exist
300
+ }
301
+
302
+ return NextResponse.json({
303
+ success: true,
304
+ deleted: deleted.length,
305
+ errors: errors.length,
306
+ })
307
+ } catch (error) {
308
+ console.error('Failed to delete orphans:', error)
309
+ return NextResponse.json({ error: 'Failed to delete orphaned files' }, { status: 500 })
310
+ }
311
+ }
@@ -0,0 +1,234 @@
1
+ import { promises as fs } from 'fs'
2
+ import { S3Client, GetObjectCommand, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3'
3
+ import { getAllThumbnailPaths } from '../../types'
4
+ import { getContentType } from './files'
5
+ import { getPublicPath } from '../../config'
6
+
7
+ /**
8
+ * Purge URLs from Cloudflare cache
9
+ * Requires CLOUDFLARE_ZONE_ID and CLOUDFLARE_API_TOKEN environment variables
10
+ */
11
+ export async function purgeCloudflareCache(urls: string[]): Promise<void> {
12
+ const zoneId = process.env.CLOUDFLARE_ZONE_ID
13
+ const apiToken = process.env.CLOUDFLARE_API_TOKEN
14
+
15
+ if (!zoneId || !apiToken || urls.length === 0) {
16
+ return // Cache purge not configured or no URLs to purge
17
+ }
18
+
19
+ try {
20
+ const response = await fetch(
21
+ `https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`,
22
+ {
23
+ method: 'POST',
24
+ headers: {
25
+ 'Authorization': `Bearer ${apiToken}`,
26
+ 'Content-Type': 'application/json',
27
+ },
28
+ body: JSON.stringify({ files: urls }),
29
+ }
30
+ )
31
+
32
+ if (!response.ok) {
33
+ console.error('Cache purge failed:', await response.text())
34
+ }
35
+ } catch (error) {
36
+ console.error('Cache purge error:', error)
37
+ }
38
+ }
39
+
40
+ function getR2Client() {
41
+ const accountId = process.env.CLOUDFLARE_R2_ACCOUNT_ID
42
+ const accessKeyId = process.env.CLOUDFLARE_R2_ACCESS_KEY_ID
43
+ const secretAccessKey = process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY
44
+
45
+ if (!accountId || !accessKeyId || !secretAccessKey) {
46
+ throw new Error('R2 not configured')
47
+ }
48
+
49
+ return new S3Client({
50
+ region: 'auto',
51
+ endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
52
+ credentials: { accessKeyId, secretAccessKey },
53
+ })
54
+ }
55
+
56
+ export async function downloadFromCdn(originalPath: string): Promise<Buffer> {
57
+ const bucketName = process.env.CLOUDFLARE_R2_BUCKET_NAME
58
+ if (!bucketName) throw new Error('R2 bucket not configured')
59
+
60
+ const r2 = getR2Client()
61
+ const maxRetries = 3
62
+ let lastError: Error | undefined
63
+
64
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
65
+ try {
66
+ const response = await r2.send(
67
+ new GetObjectCommand({
68
+ Bucket: bucketName,
69
+ Key: originalPath.replace(/^\//, ''),
70
+ })
71
+ )
72
+
73
+ const stream = response.Body as NodeJS.ReadableStream
74
+ const chunks: Buffer[] = []
75
+ for await (const chunk of stream) {
76
+ chunks.push(Buffer.from(chunk))
77
+ }
78
+ return Buffer.concat(chunks)
79
+ } catch (error) {
80
+ lastError = error as Error
81
+ // Wait before retry (exponential backoff: 500ms, 1s)
82
+ if (attempt < maxRetries - 1) {
83
+ await new Promise(resolve => setTimeout(resolve, 500 * (attempt + 1)))
84
+ }
85
+ }
86
+ }
87
+
88
+ throw lastError || new Error(`Failed to download ${originalPath} after ${maxRetries} attempts`)
89
+ }
90
+
91
+ export async function uploadToCdn(imageKey: string): Promise<void> {
92
+ const bucketName = process.env.CLOUDFLARE_R2_BUCKET_NAME
93
+ if (!bucketName) throw new Error('R2 bucket not configured')
94
+
95
+ const r2 = getR2Client()
96
+
97
+ // Upload all thumbnail sizes derived from imageKey
98
+ for (const thumbPath of getAllThumbnailPaths(imageKey)) {
99
+ const localPath = getPublicPath(thumbPath)
100
+ try {
101
+ const fileBuffer = await fs.readFile(localPath)
102
+ await r2.send(
103
+ new PutObjectCommand({
104
+ Bucket: bucketName,
105
+ Key: thumbPath.replace(/^\//, ''),
106
+ Body: fileBuffer,
107
+ ContentType: getContentType(thumbPath),
108
+ })
109
+ )
110
+ } catch {
111
+ // File might not exist (e.g., if image is smaller than thumbnail size)
112
+ }
113
+ }
114
+ }
115
+
116
+ export async function deleteLocalThumbnails(imageKey: string): Promise<void> {
117
+ for (const thumbPath of getAllThumbnailPaths(imageKey)) {
118
+ const localPath = getPublicPath(thumbPath)
119
+ try {
120
+ await fs.unlink(localPath)
121
+ } catch {
122
+ // File might not exist
123
+ }
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Download image from a remote URL (not R2)
129
+ */
130
+ export async function downloadFromRemoteUrl(url: string): Promise<Buffer> {
131
+ const maxRetries = 3
132
+ let lastError: Error | undefined
133
+
134
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
135
+ try {
136
+ const response = await fetch(url)
137
+ if (!response.ok) {
138
+ throw new Error(`Failed to download from ${url}: ${response.status}`)
139
+ }
140
+ const arrayBuffer = await response.arrayBuffer()
141
+ return Buffer.from(arrayBuffer)
142
+ } catch (error) {
143
+ lastError = error as Error
144
+ // Wait before retry (exponential backoff: 500ms, 1s)
145
+ if (attempt < maxRetries - 1) {
146
+ await new Promise(resolve => setTimeout(resolve, 500 * (attempt + 1)))
147
+ }
148
+ }
149
+ }
150
+
151
+ throw lastError || new Error(`Failed to download from ${url} after ${maxRetries} attempts`)
152
+ }
153
+
154
+ /**
155
+ * Upload original image to R2 CDN
156
+ */
157
+ export async function uploadOriginalToCdn(imageKey: string): Promise<void> {
158
+ const bucketName = process.env.CLOUDFLARE_R2_BUCKET_NAME
159
+ if (!bucketName) throw new Error('R2 bucket not configured')
160
+
161
+ const r2 = getR2Client()
162
+ const localPath = getPublicPath(imageKey)
163
+ const fileBuffer = await fs.readFile(localPath)
164
+
165
+ await r2.send(
166
+ new PutObjectCommand({
167
+ Bucket: bucketName,
168
+ Key: imageKey.replace(/^\//, ''),
169
+ Body: fileBuffer,
170
+ ContentType: getContentType(imageKey),
171
+ })
172
+ )
173
+ }
174
+
175
+ /**
176
+ * Delete original and thumbnails from R2 CDN
177
+ */
178
+ export async function deleteFromCdn(imageKey: string, hasThumbnails: boolean): Promise<void> {
179
+ const bucketName = process.env.CLOUDFLARE_R2_BUCKET_NAME
180
+ if (!bucketName) throw new Error('R2 bucket not configured')
181
+
182
+ const r2 = getR2Client()
183
+
184
+ // Delete original
185
+ try {
186
+ await r2.send(
187
+ new DeleteObjectCommand({
188
+ Bucket: bucketName,
189
+ Key: imageKey.replace(/^\//, ''),
190
+ })
191
+ )
192
+ } catch {
193
+ // May not exist
194
+ }
195
+
196
+ // Delete thumbnails if they exist
197
+ if (hasThumbnails) {
198
+ for (const thumbPath of getAllThumbnailPaths(imageKey)) {
199
+ try {
200
+ await r2.send(
201
+ new DeleteObjectCommand({
202
+ Bucket: bucketName,
203
+ Key: thumbPath.replace(/^\//, ''),
204
+ })
205
+ )
206
+ } catch {
207
+ // May not exist
208
+ }
209
+ }
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Delete only thumbnails from R2 CDN (keeps original)
215
+ */
216
+ export async function deleteThumbnailsFromCdn(imageKey: string): Promise<void> {
217
+ const bucketName = process.env.CLOUDFLARE_R2_BUCKET_NAME
218
+ if (!bucketName) throw new Error('R2 bucket not configured')
219
+
220
+ const r2 = getR2Client()
221
+
222
+ for (const thumbPath of getAllThumbnailPaths(imageKey)) {
223
+ try {
224
+ await r2.send(
225
+ new DeleteObjectCommand({
226
+ Bucket: bucketName,
227
+ Key: thumbPath.replace(/^\//, ''),
228
+ })
229
+ )
230
+ } catch {
231
+ // May not exist
232
+ }
233
+ }
234
+ }
@@ -0,0 +1,64 @@
1
+ import { promises as fs } from 'fs'
2
+ import path from 'path'
3
+
4
+ export function isImageFile(filename: string): boolean {
5
+ const ext = path.extname(filename).toLowerCase()
6
+ return ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.ico', '.bmp', '.tiff', '.tif'].includes(ext)
7
+ }
8
+
9
+ export function isMediaFile(filename: string): boolean {
10
+ const ext = path.extname(filename).toLowerCase()
11
+ // Images
12
+ if (['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.ico', '.bmp', '.tiff', '.tif'].includes(ext)) return true
13
+ // Videos
14
+ if (['.mp4', '.webm', '.mov', '.avi', '.mkv', '.m4v'].includes(ext)) return true
15
+ // Audio
16
+ if (['.mp3', '.wav', '.ogg', '.m4a', '.flac', '.aac'].includes(ext)) return true
17
+ // Documents/PDFs
18
+ if (['.pdf'].includes(ext)) return true
19
+ return false
20
+ }
21
+
22
+ export function getContentType(filePath: string): string {
23
+ const ext = path.extname(filePath).toLowerCase()
24
+ switch (ext) {
25
+ case '.jpg':
26
+ case '.jpeg':
27
+ return 'image/jpeg'
28
+ case '.png':
29
+ return 'image/png'
30
+ case '.gif':
31
+ return 'image/gif'
32
+ case '.webp':
33
+ return 'image/webp'
34
+ case '.svg':
35
+ return 'image/svg+xml'
36
+ default:
37
+ return 'application/octet-stream'
38
+ }
39
+ }
40
+
41
+ export async function getFolderStats(folderPath: string): Promise<{ fileCount: number; totalSize: number }> {
42
+ let fileCount = 0
43
+ let totalSize = 0
44
+
45
+ async function scanFolder(dir: string): Promise<void> {
46
+ try {
47
+ const entries = await fs.readdir(dir, { withFileTypes: true })
48
+ for (const entry of entries) {
49
+ if (entry.name.startsWith('.')) continue
50
+ const fullPath = path.join(dir, entry.name)
51
+ if (entry.isDirectory()) {
52
+ await scanFolder(fullPath)
53
+ } else if (isMediaFile(entry.name)) {
54
+ fileCount++
55
+ const stats = await fs.stat(fullPath)
56
+ totalSize += stats.size
57
+ }
58
+ }
59
+ } catch { /* ignore errors */ }
60
+ }
61
+
62
+ await scanFolder(folderPath)
63
+ return { fileCount, totalSize }
64
+ }
@@ -0,0 +1,4 @@
1
+ export { loadMeta, saveMeta, getCdnUrls, setCdnUrls, getOrAddCdnIndex, getMetaEntry, setMetaEntry, deleteMetaEntry, getFileEntries } from './meta'
2
+ export { isImageFile, isMediaFile, getContentType, getFolderStats } from './files'
3
+ export { processImage, DEFAULT_SIZES } from './thumbnails'
4
+ export { downloadFromCdn, uploadToCdn, deleteLocalThumbnails, downloadFromRemoteUrl, uploadOriginalToCdn, deleteFromCdn, deleteThumbnailsFromCdn, purgeCloudflareCache } from './cdn'