@gallop.software/studio 0.1.87 → 0.1.89

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.
@@ -1,15 +1,15 @@
1
1
  import {
2
- getAllThumbnailPaths
3
- } from "../chunk-3RI33B7A.mjs";
2
+ getAllThumbnailPaths,
3
+ getThumbnailPath
4
+ } from "../chunk-QR2FRA4L.mjs";
4
5
 
5
6
  // src/handlers/index.ts
6
7
  import { NextResponse as NextResponse4 } from "next/server";
7
8
 
8
9
  // src/handlers/list.ts
9
10
  import { NextResponse } from "next/server";
10
- import { promises as fs5 } from "fs";
11
+ import { promises as fs4 } from "fs";
11
12
  import path5 from "path";
12
- import sharp2 from "sharp";
13
13
 
14
14
  // src/handlers/utils/meta.ts
15
15
  import { promises as fs } from "fs";
@@ -31,7 +31,6 @@ async function saveMeta(meta) {
31
31
  }
32
32
 
33
33
  // src/handlers/utils/files.ts
34
- import { promises as fs2 } from "fs";
35
34
  import path2 from "path";
36
35
  function isImageFile(filename) {
37
36
  const ext = path2.extname(filename).toLowerCase();
@@ -63,32 +62,9 @@ function getContentType(filePath) {
63
62
  return "application/octet-stream";
64
63
  }
65
64
  }
66
- async function getFolderStats(folderPath) {
67
- let fileCount = 0;
68
- let totalSize = 0;
69
- async function scanFolder(dir) {
70
- try {
71
- const entries = await fs2.readdir(dir, { withFileTypes: true });
72
- for (const entry of entries) {
73
- if (entry.name.startsWith(".")) continue;
74
- const fullPath = path2.join(dir, entry.name);
75
- if (entry.isDirectory()) {
76
- await scanFolder(fullPath);
77
- } else if (isMediaFile(entry.name)) {
78
- fileCount++;
79
- const stats = await fs2.stat(fullPath);
80
- totalSize += stats.size;
81
- }
82
- }
83
- } catch {
84
- }
85
- }
86
- await scanFolder(folderPath);
87
- return { fileCount, totalSize };
88
- }
89
65
 
90
66
  // src/handlers/utils/thumbnails.ts
91
- import { promises as fs3 } from "fs";
67
+ import { promises as fs2 } from "fs";
92
68
  import path3 from "path";
93
69
  import sharp from "sharp";
94
70
  import { encode } from "blurhash";
@@ -107,7 +83,7 @@ async function processImage(buffer, imageKey) {
107
83
  const ext = path3.extname(keyWithoutSlash).toLowerCase();
108
84
  const imageDir = path3.dirname(keyWithoutSlash);
109
85
  const imagesPath = path3.join(process.cwd(), "public", "images", imageDir === "." ? "" : imageDir);
110
- await fs3.mkdir(imagesPath, { recursive: true });
86
+ await fs2.mkdir(imagesPath, { recursive: true });
111
87
  const isPng = ext === ".png";
112
88
  const outputExt = isPng ? ".png" : ".jpg";
113
89
  const fullFileName = imageDir === "." ? `${baseName}${outputExt}` : `${imageDir}/${baseName}${outputExt}`;
@@ -143,7 +119,7 @@ async function processImage(buffer, imageKey) {
143
119
  }
144
120
 
145
121
  // src/handlers/utils/cdn.ts
146
- import { promises as fs4 } from "fs";
122
+ import { promises as fs3 } from "fs";
147
123
  import path4 from "path";
148
124
  import { S3Client, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
149
125
  function getR2Client() {
@@ -183,7 +159,7 @@ async function uploadToCdn(imageKey) {
183
159
  for (const thumbPath of getAllThumbnailPaths(imageKey)) {
184
160
  const localPath = path4.join(process.cwd(), "public", thumbPath);
185
161
  try {
186
- const fileBuffer = await fs4.readFile(localPath);
162
+ const fileBuffer = await fs3.readFile(localPath);
187
163
  await r2.send(
188
164
  new PutObjectCommand({
189
165
  Bucket: bucketName,
@@ -200,7 +176,7 @@ async function deleteLocalThumbnails(imageKey) {
200
176
  for (const thumbPath of getAllThumbnailPaths(imageKey)) {
201
177
  const localPath = path4.join(process.cwd(), "public", thumbPath);
202
178
  try {
203
- await fs4.unlink(localPath);
179
+ await fs3.unlink(localPath);
204
180
  } catch {
205
181
  }
206
182
  }
@@ -211,70 +187,85 @@ async function handleList(request) {
211
187
  const searchParams = request.nextUrl.searchParams;
212
188
  const requestedPath = searchParams.get("path") || "public";
213
189
  try {
214
- const safePath = requestedPath.replace(/\.\./g, "");
215
- const absolutePath = path5.join(process.cwd(), safePath);
216
- if (!absolutePath.startsWith(process.cwd())) {
217
- return NextResponse.json({ error: "Invalid path" }, { status: 400 });
190
+ const meta = await loadMeta();
191
+ const metaKeys = Object.keys(meta);
192
+ if (metaKeys.length === 0) {
193
+ return NextResponse.json({ items: [], isEmpty: true });
218
194
  }
195
+ const relativePath = requestedPath.replace(/^public\/?/, "");
196
+ const pathPrefix = relativePath ? `/${relativePath}/` : "/";
219
197
  const items = [];
220
- const entries = await fs5.readdir(absolutePath, { withFileTypes: true });
221
- for (const entry of entries) {
222
- if (entry.name.startsWith(".")) continue;
223
- const itemPath = path5.join(safePath, entry.name);
224
- if (entry.isDirectory()) {
225
- const folderStats = await getFolderStats(path5.join(absolutePath, entry.name));
226
- items.push({
227
- name: entry.name,
228
- path: itemPath,
229
- type: "folder",
230
- fileCount: folderStats.fileCount,
231
- totalSize: folderStats.totalSize
232
- });
233
- } else if (isMediaFile(entry.name)) {
234
- const filePath = path5.join(absolutePath, entry.name);
235
- const stats = await fs5.stat(filePath);
236
- const isImage = isImageFile(entry.name);
198
+ const seenFolders = /* @__PURE__ */ new Set();
199
+ for (const key of metaKeys) {
200
+ const entry = meta[key];
201
+ if (!key.startsWith(pathPrefix) && pathPrefix !== "/") continue;
202
+ if (pathPrefix === "/" && !key.startsWith("/")) continue;
203
+ const remaining = pathPrefix === "/" ? key.slice(1) : key.slice(pathPrefix.length);
204
+ if (!remaining) continue;
205
+ const slashIndex = remaining.indexOf("/");
206
+ if (slashIndex !== -1) {
207
+ const folderName = remaining.slice(0, slashIndex);
208
+ if (!seenFolders.has(folderName)) {
209
+ seenFolders.add(folderName);
210
+ const folderPrefix = pathPrefix === "/" ? `/${folderName}/` : `${pathPrefix}${folderName}/`;
211
+ let fileCount = 0;
212
+ for (const k of metaKeys) {
213
+ if (k.startsWith(folderPrefix)) fileCount++;
214
+ }
215
+ items.push({
216
+ name: folderName,
217
+ path: relativePath ? `public/${relativePath}/${folderName}` : `public/${folderName}`,
218
+ type: "folder",
219
+ fileCount
220
+ });
221
+ }
222
+ } else {
223
+ const fileName = remaining;
224
+ const isImage = isImageFile(fileName);
225
+ const isSynced = entry.s === 1;
237
226
  let thumbnail;
238
227
  let hasThumbnail = false;
239
- let dimensions;
240
- if (isImage) {
241
- const relativePath = safePath.replace(/^public\/?/, "");
242
- if (relativePath === "images" || relativePath.startsWith("images/")) {
243
- thumbnail = itemPath.replace("public", "");
244
- hasThumbnail = true;
228
+ let fileSize;
229
+ if (isImage && (entry.w || entry.blur)) {
230
+ const thumbPath = getThumbnailPath(key, "sm");
231
+ if (isSynced) {
232
+ const cdnUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL || process.env.NEXT_PUBLIC_CLOUDFLARE_R2_PUBLIC_URL;
233
+ if (cdnUrl) {
234
+ thumbnail = `${cdnUrl}${thumbPath}`;
235
+ hasThumbnail = true;
236
+ }
245
237
  } else {
246
- const ext = path5.extname(entry.name).toLowerCase();
247
- const baseName = path5.basename(entry.name, ext);
248
- const thumbnailDir = relativePath ? `images/${relativePath}` : "images";
249
- const thumbnailName = `${baseName}-sm${ext === ".png" ? ".png" : ".jpg"}`;
250
- const thumbnailPath = path5.join(process.cwd(), "public", thumbnailDir, thumbnailName);
238
+ const localThumbPath = path5.join(process.cwd(), "public", thumbPath);
251
239
  try {
252
- await fs5.access(thumbnailPath);
253
- thumbnail = `/${thumbnailDir}/${thumbnailName}`;
240
+ await fs4.access(localThumbPath);
241
+ thumbnail = thumbPath;
254
242
  hasThumbnail = true;
255
243
  } catch {
256
- thumbnail = itemPath.replace("public", "");
244
+ thumbnail = key;
257
245
  hasThumbnail = false;
258
246
  }
259
247
  }
260
- if (!entry.name.toLowerCase().endsWith(".svg")) {
261
- try {
262
- const metadata = await sharp2(filePath).metadata();
263
- if (metadata.width && metadata.height) {
264
- dimensions = { width: metadata.width, height: metadata.height };
265
- }
266
- } catch {
267
- }
248
+ } else if (isImage) {
249
+ thumbnail = key;
250
+ hasThumbnail = false;
251
+ }
252
+ if (!isSynced) {
253
+ try {
254
+ const filePath = path5.join(process.cwd(), "public", key);
255
+ const stats = await fs4.stat(filePath);
256
+ fileSize = stats.size;
257
+ } catch {
268
258
  }
269
259
  }
270
260
  items.push({
271
- name: entry.name,
272
- path: itemPath,
261
+ name: fileName,
262
+ path: relativePath ? `public/${relativePath}/${fileName}` : `public/${fileName}`,
273
263
  type: "file",
274
- size: stats.size,
264
+ size: fileSize,
275
265
  thumbnail,
276
266
  hasThumbnail,
277
- dimensions
267
+ cdnSynced: isSynced,
268
+ dimensions: entry.w && entry.h ? { width: entry.w, height: entry.h } : void 0
278
269
  });
279
270
  }
280
271
  }
@@ -291,62 +282,49 @@ async function handleSearch(request) {
291
282
  return NextResponse.json({ items: [] });
292
283
  }
293
284
  try {
285
+ const meta = await loadMeta();
294
286
  const items = [];
295
- const publicDir = path5.join(process.cwd(), "public");
296
- async function searchDir(dir, relativePath) {
297
- try {
298
- const entries = await fs5.readdir(dir, { withFileTypes: true });
299
- for (const entry of entries) {
300
- if (entry.name.startsWith(".")) continue;
301
- const fullPath = path5.join(dir, entry.name);
302
- const itemPath = relativePath ? `public/${relativePath}/${entry.name}` : `public/${entry.name}`;
303
- const itemRelPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
304
- if (entry.isDirectory()) {
305
- await searchDir(fullPath, itemRelPath);
306
- } else if (isImageFile(entry.name)) {
307
- if (itemPath.toLowerCase().includes(query)) {
308
- const stats = await fs5.stat(fullPath);
309
- let thumbnail;
310
- let hasThumbnail = false;
311
- let dimensions;
312
- const ext = path5.extname(entry.name).toLowerCase();
313
- const baseName = path5.basename(entry.name, ext);
314
- const thumbnailDir = relativePath ? `images/${relativePath}` : "images";
315
- const thumbnailName = `${baseName}-sm${ext === ".png" ? ".png" : ".jpg"}`;
316
- const thumbnailPath = path5.join(process.cwd(), "public", thumbnailDir, thumbnailName);
317
- try {
318
- await fs5.access(thumbnailPath);
319
- thumbnail = `/${thumbnailDir}/${thumbnailName}`;
320
- hasThumbnail = true;
321
- } catch {
322
- thumbnail = `/${itemRelPath}`;
323
- hasThumbnail = false;
324
- }
325
- if (!entry.name.toLowerCase().endsWith(".svg")) {
326
- try {
327
- const metadata = await sharp2(fullPath).metadata();
328
- if (metadata.width && metadata.height) {
329
- dimensions = { width: metadata.width, height: metadata.height };
330
- }
331
- } catch {
332
- }
333
- }
334
- items.push({
335
- name: entry.name,
336
- path: itemPath,
337
- type: "file",
338
- size: stats.size,
339
- thumbnail,
340
- hasThumbnail,
341
- dimensions
342
- });
343
- }
287
+ for (const [key, entry] of Object.entries(meta)) {
288
+ if (!key.toLowerCase().includes(query)) continue;
289
+ const fileName = path5.basename(key);
290
+ const relativePath = key.slice(1);
291
+ const isImage = isImageFile(fileName);
292
+ const isSynced = entry.s === 1;
293
+ let thumbnail;
294
+ let hasThumbnail = false;
295
+ if (isImage && (entry.w || entry.blur)) {
296
+ const thumbPath = getThumbnailPath(key, "sm");
297
+ if (isSynced) {
298
+ const cdnUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL || process.env.NEXT_PUBLIC_CLOUDFLARE_R2_PUBLIC_URL;
299
+ if (cdnUrl) {
300
+ thumbnail = `${cdnUrl}${thumbPath}`;
301
+ hasThumbnail = true;
302
+ }
303
+ } else {
304
+ const localThumbPath = path5.join(process.cwd(), "public", thumbPath);
305
+ try {
306
+ await fs4.access(localThumbPath);
307
+ thumbnail = thumbPath;
308
+ hasThumbnail = true;
309
+ } catch {
310
+ thumbnail = key;
311
+ hasThumbnail = false;
344
312
  }
345
313
  }
346
- } catch {
314
+ } else if (isImage) {
315
+ thumbnail = key;
316
+ hasThumbnail = false;
347
317
  }
318
+ items.push({
319
+ name: fileName,
320
+ path: `public/${relativePath}`,
321
+ type: "file",
322
+ thumbnail,
323
+ hasThumbnail,
324
+ cdnSynced: isSynced,
325
+ dimensions: entry.w && entry.h ? { width: entry.w, height: entry.h } : void 0
326
+ });
348
327
  }
349
- await searchDir(publicDir, "");
350
328
  return NextResponse.json({ items });
351
329
  } catch (error) {
352
330
  console.error("Failed to search:", error);
@@ -355,27 +333,28 @@ async function handleSearch(request) {
355
333
  }
356
334
  async function handleListFolders() {
357
335
  try {
358
- const publicDir = path5.join(process.cwd(), "public");
359
- const folders = [];
360
- async function scanDir(dir, relativePath, depth) {
361
- try {
362
- const entries = await fs5.readdir(dir, { withFileTypes: true });
363
- for (const entry of entries) {
364
- if (!entry.isDirectory()) continue;
365
- if (entry.name.startsWith(".")) continue;
366
- const folderRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
367
- folders.push({
368
- path: `public/${folderRelativePath}`,
369
- name: entry.name,
370
- depth
371
- });
372
- await scanDir(path5.join(dir, entry.name), folderRelativePath, depth + 1);
373
- }
374
- } catch {
336
+ const meta = await loadMeta();
337
+ const folderSet = /* @__PURE__ */ new Set();
338
+ for (const key of Object.keys(meta)) {
339
+ const parts = key.split("/");
340
+ let current = "";
341
+ for (let i = 1; i < parts.length - 1; i++) {
342
+ current = current ? `${current}/${parts[i]}` : parts[i];
343
+ folderSet.add(current);
375
344
  }
376
345
  }
346
+ const folders = [];
377
347
  folders.push({ path: "public", name: "public", depth: 0 });
378
- await scanDir(publicDir, "", 1);
348
+ const sortedFolders = Array.from(folderSet).sort();
349
+ for (const folderPath of sortedFolders) {
350
+ const depth = folderPath.split("/").length;
351
+ const name = folderPath.split("/").pop() || folderPath;
352
+ folders.push({
353
+ path: `public/${folderPath}`,
354
+ name,
355
+ depth
356
+ });
357
+ }
379
358
  return NextResponse.json({ folders });
380
359
  } catch (error) {
381
360
  console.error("Failed to list folders:", error);
@@ -384,26 +363,14 @@ async function handleListFolders() {
384
363
  }
385
364
  async function handleCountImages() {
386
365
  try {
366
+ const meta = await loadMeta();
387
367
  const allImages = [];
388
- async function scanPublicFolder(dir, relativePath = "") {
389
- try {
390
- const entries = await fs5.readdir(dir, { withFileTypes: true });
391
- for (const entry of entries) {
392
- if (entry.name.startsWith(".")) continue;
393
- const fullPath = path5.join(dir, entry.name);
394
- const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
395
- if (relPath === "images" || relPath.startsWith("images/")) continue;
396
- if (entry.isDirectory()) {
397
- await scanPublicFolder(fullPath, relPath);
398
- } else if (isImageFile(entry.name)) {
399
- allImages.push(relPath);
400
- }
401
- }
402
- } catch {
368
+ for (const key of Object.keys(meta)) {
369
+ const fileName = path5.basename(key);
370
+ if (isImageFile(fileName)) {
371
+ allImages.push(key.slice(1));
403
372
  }
404
373
  }
405
- const publicDir = path5.join(process.cwd(), "public");
406
- await scanPublicFolder(publicDir);
407
374
  return NextResponse.json({
408
375
  count: allImages.length,
409
376
  images: allImages
@@ -421,29 +388,22 @@ async function handleFolderImages(request) {
421
388
  return NextResponse.json({ error: "No folders provided" }, { status: 400 });
422
389
  }
423
390
  const folders = foldersParam.split(",");
391
+ const meta = await loadMeta();
424
392
  const allImages = [];
425
- async function scanFolder(dir, relativePath = "") {
426
- try {
427
- const entries = await fs5.readdir(dir, { withFileTypes: true });
428
- for (const entry of entries) {
429
- if (entry.name.startsWith(".")) continue;
430
- const fullPath = path5.join(dir, entry.name);
431
- const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
432
- if (entry.isDirectory()) {
433
- await scanFolder(fullPath, relPath);
434
- } else if (isImageFile(entry.name)) {
435
- allImages.push(relPath);
436
- }
393
+ const prefixes = folders.map((f) => {
394
+ const rel = f.replace(/^public\/?/, "");
395
+ return rel ? `/${rel}/` : "/";
396
+ });
397
+ for (const key of Object.keys(meta)) {
398
+ const fileName = path5.basename(key);
399
+ if (!isImageFile(fileName)) continue;
400
+ for (const prefix of prefixes) {
401
+ if (key.startsWith(prefix) || prefix === "/" && key.startsWith("/")) {
402
+ allImages.push(key.slice(1));
403
+ break;
437
404
  }
438
- } catch {
439
405
  }
440
406
  }
441
- for (const folder of folders) {
442
- const relativePath = folder.replace(/^public\/?/, "");
443
- if (relativePath === "images" || relativePath.startsWith("images/")) continue;
444
- const folderPath = path5.join(process.cwd(), folder);
445
- await scanFolder(folderPath, relativePath);
446
- }
447
407
  return NextResponse.json({
448
408
  count: allImages.length,
449
409
  images: allImages
@@ -456,10 +416,9 @@ async function handleFolderImages(request) {
456
416
 
457
417
  // src/handlers/files.ts
458
418
  import { NextResponse as NextResponse2 } from "next/server";
459
- import { promises as fs6 } from "fs";
419
+ import { promises as fs5 } from "fs";
460
420
  import path6 from "path";
461
- import sharp3 from "sharp";
462
- import { encode as encode2 } from "blurhash";
421
+ import sharp2 from "sharp";
463
422
  async function handleUpload(request) {
464
423
  try {
465
424
  const formData = await request.formData();
@@ -471,11 +430,9 @@ async function handleUpload(request) {
471
430
  const bytes = await file.arrayBuffer();
472
431
  const buffer = Buffer.from(bytes);
473
432
  const fileName = file.name;
474
- const baseName = path6.basename(fileName, path6.extname(fileName));
475
433
  const ext = path6.extname(fileName).toLowerCase();
476
434
  const isImage = isImageFile(fileName);
477
- const isSvg = ext === ".svg";
478
- const isProcessableImage = isImage && !isSvg;
435
+ const isMedia = isMediaFile(fileName);
479
436
  const meta = await loadMeta();
480
437
  let relativeDir = "";
481
438
  if (targetPath === "public") {
@@ -489,71 +446,49 @@ async function handleUpload(request) {
489
446
  { status: 400 }
490
447
  );
491
448
  }
449
+ let imageKey = "/" + (relativeDir ? `${relativeDir}/${fileName}` : fileName);
450
+ if (meta[imageKey]) {
451
+ const baseName = path6.basename(fileName, ext);
452
+ let counter = 1;
453
+ let newFileName = `${baseName}-${counter}${ext}`;
454
+ let newKey = "/" + (relativeDir ? `${relativeDir}/${newFileName}` : newFileName);
455
+ while (meta[newKey]) {
456
+ counter++;
457
+ newFileName = `${baseName}-${counter}${ext}`;
458
+ newKey = "/" + (relativeDir ? `${relativeDir}/${newFileName}` : newFileName);
459
+ }
460
+ imageKey = newKey;
461
+ }
462
+ const actualFileName = path6.basename(imageKey);
492
463
  const uploadDir = path6.join(process.cwd(), "public", relativeDir);
493
- await fs6.mkdir(uploadDir, { recursive: true });
494
- await fs6.writeFile(path6.join(uploadDir, fileName), buffer);
495
- if (!isImage) {
464
+ await fs5.mkdir(uploadDir, { recursive: true });
465
+ await fs5.writeFile(path6.join(uploadDir, actualFileName), buffer);
466
+ if (!isMedia) {
496
467
  return NextResponse2.json({
497
468
  success: true,
498
- message: "File uploaded successfully (non-image, no thumbnails generated)",
499
- path: `public/${relativeDir ? relativeDir + "/" : ""}${fileName}`
469
+ message: "File uploaded (not a media file)",
470
+ path: `public/${relativeDir ? relativeDir + "/" : ""}${actualFileName}`
500
471
  });
501
472
  }
502
- const imageKey = "/" + (relativeDir ? `${relativeDir}/${fileName}` : fileName);
503
- if (meta[imageKey]) {
504
- return NextResponse2.json(
505
- { error: `File '${imageKey}' already exists in meta` },
506
- { status: 409 }
507
- );
508
- }
509
- const imagesPath = path6.join(process.cwd(), "public", "images", relativeDir);
510
- await fs6.mkdir(imagesPath, { recursive: true });
511
- let originalWidth = 0;
512
- let originalHeight = 0;
513
- let blurhash = "";
514
- const originalPath = `/${relativeDir ? relativeDir + "/" : ""}${fileName}`;
515
- if (isSvg) {
516
- const fullPath = path6.join(imagesPath, fileName);
517
- await fs6.writeFile(fullPath, buffer);
518
- } else if (isProcessableImage) {
519
- const sharpInstance = sharp3(buffer);
520
- const metadata = await sharpInstance.metadata();
521
- originalWidth = metadata.width || 0;
522
- originalHeight = metadata.height || 0;
523
- const outputExt = ext === ".png" ? ".png" : ".jpg";
524
- const fullFileName = `${baseName}${outputExt}`;
525
- const fullPath = path6.join(imagesPath, fullFileName);
526
- if (ext === ".png") {
527
- await sharp3(buffer).png({ quality: 85 }).toFile(fullPath);
528
- } else {
529
- await sharp3(buffer).jpeg({ quality: 85 }).toFile(fullPath);
530
- }
531
- for (const [, sizeConfig] of Object.entries(DEFAULT_SIZES)) {
532
- const { width: maxWidth, suffix } = sizeConfig;
533
- if (originalWidth <= maxWidth) {
534
- continue;
535
- }
536
- const ratio = originalHeight / originalWidth;
537
- const newHeight = Math.round(maxWidth * ratio);
538
- const sizeFileName = `${baseName}${suffix}${outputExt}`;
539
- const sizePath = path6.join(imagesPath, sizeFileName);
540
- if (ext === ".png") {
541
- await sharp3(buffer).resize(maxWidth, newHeight).png({ quality: 80 }).toFile(sizePath);
542
- } else {
543
- await sharp3(buffer).resize(maxWidth, newHeight).jpeg({ quality: 80 }).toFile(sizePath);
544
- }
473
+ if (isImage && ext !== ".svg") {
474
+ try {
475
+ const metadata = await sharp2(buffer).metadata();
476
+ meta[imageKey] = {
477
+ w: metadata.width || 0,
478
+ h: metadata.height || 0
479
+ };
480
+ } catch {
481
+ meta[imageKey] = { w: 0, h: 0 };
545
482
  }
546
- const { data, info } = await sharp3(buffer).resize(32, 32, { fit: "inside" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
547
- blurhash = encode2(new Uint8ClampedArray(data), info.width, info.height, 4, 4);
483
+ } else {
484
+ meta[imageKey] = {};
548
485
  }
549
- const entry = {
550
- w: originalWidth,
551
- h: originalHeight,
552
- blur: blurhash
553
- };
554
- meta[originalPath] = entry;
555
486
  await saveMeta(meta);
556
- return NextResponse2.json({ success: true, imageKey: originalPath, entry });
487
+ return NextResponse2.json({
488
+ success: true,
489
+ imageKey,
490
+ message: 'File uploaded. Run "Process Images" to generate thumbnails.'
491
+ });
557
492
  } catch (error) {
558
493
  console.error("Failed to upload:", error);
559
494
  const message = error instanceof Error ? error.message : "Unknown error";
@@ -576,31 +511,61 @@ async function handleDelete(request) {
576
511
  continue;
577
512
  }
578
513
  const absolutePath = path6.join(process.cwd(), itemPath);
579
- const stats = await fs6.stat(absolutePath);
580
- if (stats.isDirectory()) {
581
- await fs6.rm(absolutePath, { recursive: true });
582
- const prefix = "/" + itemPath.replace(/^public\/images\/?/, "").replace(/^public\/?/, "");
583
- for (const key of Object.keys(meta)) {
584
- if (key.startsWith(prefix)) {
585
- delete meta[key];
514
+ const imageKey = "/" + itemPath.replace(/^public\//, "");
515
+ const entry = meta[imageKey];
516
+ const isSynced = entry?.s === 1;
517
+ try {
518
+ const stats = await fs5.stat(absolutePath);
519
+ if (stats.isDirectory()) {
520
+ await fs5.rm(absolutePath, { recursive: true });
521
+ const prefix = imageKey + "/";
522
+ for (const key of Object.keys(meta)) {
523
+ if (key.startsWith(prefix) || key === imageKey) {
524
+ if (!meta[key].s) {
525
+ for (const thumbPath of getAllThumbnailPaths(key)) {
526
+ const absoluteThumbPath = path6.join(process.cwd(), "public", thumbPath);
527
+ try {
528
+ await fs5.unlink(absoluteThumbPath);
529
+ } catch {
530
+ }
531
+ }
532
+ }
533
+ delete meta[key];
534
+ }
586
535
  }
587
- }
588
- } else {
589
- await fs6.unlink(absolutePath);
590
- const isInImagesFolder = itemPath.startsWith("public/images/");
591
- if (!isInImagesFolder) {
592
- const imageKey = "/" + itemPath.replace(/^public\//, "");
593
- if (meta[imageKey]) {
594
- for (const thumbPath of getAllThumbnailPaths(imageKey)) {
595
- const absoluteThumbPath = path6.join(process.cwd(), "public", thumbPath);
596
- try {
597
- await fs6.unlink(absoluteThumbPath);
598
- } catch {
536
+ } else {
537
+ await fs5.unlink(absolutePath);
538
+ const isInImagesFolder = itemPath.startsWith("public/images/");
539
+ if (!isInImagesFolder && entry) {
540
+ if (!isSynced) {
541
+ for (const thumbPath of getAllThumbnailPaths(imageKey)) {
542
+ const absoluteThumbPath = path6.join(process.cwd(), "public", thumbPath);
543
+ try {
544
+ await fs5.unlink(absoluteThumbPath);
545
+ } catch {
546
+ }
599
547
  }
600
548
  }
601
549
  delete meta[imageKey];
602
550
  }
603
551
  }
552
+ } catch {
553
+ if (entry) {
554
+ delete meta[imageKey];
555
+ } else {
556
+ const prefix = imageKey + "/";
557
+ let foundAny = false;
558
+ for (const key of Object.keys(meta)) {
559
+ if (key.startsWith(prefix)) {
560
+ delete meta[key];
561
+ foundAny = true;
562
+ }
563
+ }
564
+ if (!foundAny) {
565
+ errors.push(`Not found: ${itemPath}`);
566
+ continue;
567
+ }
568
+ }
604
569
  }
605
570
  deleted.push(itemPath);
606
571
  } catch (error) {
@@ -635,11 +600,11 @@ async function handleCreateFolder(request) {
635
600
  return NextResponse2.json({ error: "Invalid path" }, { status: 400 });
636
601
  }
637
602
  try {
638
- await fs6.access(folderPath);
603
+ await fs5.access(folderPath);
639
604
  return NextResponse2.json({ error: "A folder with this name already exists" }, { status: 400 });
640
605
  } catch {
641
606
  }
642
- await fs6.mkdir(folderPath, { recursive: true });
607
+ await fs5.mkdir(folderPath, { recursive: true });
643
608
  return NextResponse2.json({ success: true, path: path6.join(safePath, sanitizedName) });
644
609
  } catch (error) {
645
610
  console.error("Failed to create folder:", error);
@@ -664,19 +629,19 @@ async function handleRename(request) {
664
629
  return NextResponse2.json({ error: "Invalid path" }, { status: 400 });
665
630
  }
666
631
  try {
667
- await fs6.access(absoluteOldPath);
632
+ await fs5.access(absoluteOldPath);
668
633
  } catch {
669
634
  return NextResponse2.json({ error: "File or folder not found" }, { status: 404 });
670
635
  }
671
636
  try {
672
- await fs6.access(absoluteNewPath);
637
+ await fs5.access(absoluteNewPath);
673
638
  return NextResponse2.json({ error: "An item with this name already exists" }, { status: 400 });
674
639
  } catch {
675
640
  }
676
- const stats = await fs6.stat(absoluteOldPath);
641
+ const stats = await fs5.stat(absoluteOldPath);
677
642
  const isFile = stats.isFile();
678
643
  const isImage = isFile && isImageFile(path6.basename(oldPath));
679
- await fs6.rename(absoluteOldPath, absoluteNewPath);
644
+ await fs5.rename(absoluteOldPath, absoluteNewPath);
680
645
  if (isImage) {
681
646
  const meta = await loadMeta();
682
647
  const oldRelativePath = safePath.replace(/^public\//, "");
@@ -690,9 +655,9 @@ async function handleRename(request) {
690
655
  for (let i = 0; i < oldThumbPaths.length; i++) {
691
656
  const oldThumbPath = path6.join(process.cwd(), "public", oldThumbPaths[i]);
692
657
  const newThumbPath = path6.join(process.cwd(), "public", newThumbPaths[i]);
693
- await fs6.mkdir(path6.dirname(newThumbPath), { recursive: true });
658
+ await fs5.mkdir(path6.dirname(newThumbPath), { recursive: true });
694
659
  try {
695
- await fs6.rename(oldThumbPath, newThumbPath);
660
+ await fs5.rename(oldThumbPath, newThumbPath);
696
661
  } catch {
697
662
  }
698
663
  }
@@ -723,7 +688,7 @@ async function handleMove(request) {
723
688
  return NextResponse2.json({ error: "Invalid destination" }, { status: 400 });
724
689
  }
725
690
  try {
726
- const destStats = await fs6.stat(absoluteDestination);
691
+ const destStats = await fs5.stat(absoluteDestination);
727
692
  if (!destStats.isDirectory()) {
728
693
  return NextResponse2.json({ error: "Destination is not a folder" }, { status: 400 });
729
694
  }
@@ -744,20 +709,20 @@ async function handleMove(request) {
744
709
  continue;
745
710
  }
746
711
  try {
747
- await fs6.access(absolutePath);
712
+ await fs5.access(absolutePath);
748
713
  } catch {
749
714
  errors.push(`${itemName} not found`);
750
715
  continue;
751
716
  }
752
717
  try {
753
- await fs6.access(newAbsolutePath);
718
+ await fs5.access(newAbsolutePath);
754
719
  errors.push(`${itemName} already exists in destination`);
755
720
  continue;
756
721
  } catch {
757
722
  }
758
723
  try {
759
- await fs6.rename(absolutePath, newAbsolutePath);
760
- const stats = await fs6.stat(newAbsolutePath);
724
+ await fs5.rename(absolutePath, newAbsolutePath);
725
+ const stats = await fs5.stat(newAbsolutePath);
761
726
  if (stats.isFile() && isImageFile(itemName)) {
762
727
  const oldRelativePath = safePath.replace(/^public\//, "");
763
728
  const newRelativePath = path6.join(safeDestination.replace(/^public\//, ""), itemName);
@@ -770,9 +735,9 @@ async function handleMove(request) {
770
735
  for (let i = 0; i < oldThumbPaths.length; i++) {
771
736
  const oldThumbPath = path6.join(process.cwd(), "public", oldThumbPaths[i]);
772
737
  const newThumbPath = path6.join(process.cwd(), "public", newThumbPaths[i]);
773
- await fs6.mkdir(path6.dirname(newThumbPath), { recursive: true });
738
+ await fs5.mkdir(path6.dirname(newThumbPath), { recursive: true });
774
739
  try {
775
- await fs6.rename(oldThumbPath, newThumbPath);
740
+ await fs5.rename(oldThumbPath, newThumbPath);
776
741
  } catch {
777
742
  }
778
743
  }
@@ -802,7 +767,7 @@ async function handleMove(request) {
802
767
 
803
768
  // src/handlers/images.ts
804
769
  import { NextResponse as NextResponse3 } from "next/server";
805
- import { promises as fs7 } from "fs";
770
+ import { promises as fs6 } from "fs";
806
771
  import path7 from "path";
807
772
  import { S3Client as S3Client2, PutObjectCommand as PutObjectCommand2 } from "@aws-sdk/client-s3";
808
773
  async function handleSync(request) {
@@ -833,7 +798,7 @@ async function handleSync(request) {
833
798
  for (const imageKey of imageKeys) {
834
799
  const entry = meta[imageKey];
835
800
  if (!entry) {
836
- errors.push(`Image not found in meta: ${imageKey}`);
801
+ errors.push(`Image not found in meta: ${imageKey}. Run Scan first.`);
837
802
  continue;
838
803
  }
839
804
  if (entry.s) {
@@ -841,10 +806,25 @@ async function handleSync(request) {
841
806
  continue;
842
807
  }
843
808
  try {
809
+ const originalLocalPath = path7.join(process.cwd(), "public", imageKey);
810
+ try {
811
+ const originalBuffer = await fs6.readFile(originalLocalPath);
812
+ await r2.send(
813
+ new PutObjectCommand2({
814
+ Bucket: bucketName,
815
+ Key: imageKey.replace(/^\//, ""),
816
+ Body: originalBuffer,
817
+ ContentType: getContentType(imageKey)
818
+ })
819
+ );
820
+ } catch (err) {
821
+ errors.push(`Original file not found: ${imageKey}`);
822
+ continue;
823
+ }
844
824
  for (const thumbPath of getAllThumbnailPaths(imageKey)) {
845
825
  const localPath = path7.join(process.cwd(), "public", thumbPath);
846
826
  try {
847
- const fileBuffer = await fs7.readFile(localPath);
827
+ const fileBuffer = await fs6.readFile(localPath);
848
828
  await r2.send(
849
829
  new PutObjectCommand2({
850
830
  Bucket: bucketName,
@@ -860,14 +840,18 @@ async function handleSync(request) {
860
840
  for (const thumbPath of getAllThumbnailPaths(imageKey)) {
861
841
  const localPath = path7.join(process.cwd(), "public", thumbPath);
862
842
  try {
863
- await fs7.unlink(localPath);
843
+ await fs6.unlink(localPath);
864
844
  } catch {
865
845
  }
866
846
  }
847
+ try {
848
+ await fs6.unlink(originalLocalPath);
849
+ } catch {
850
+ }
867
851
  synced.push(imageKey);
868
852
  } catch (error) {
869
853
  console.error(`Failed to sync ${imageKey}:`, error);
870
- errors.push(imageKey);
854
+ errors.push(`Failed to sync: ${imageKey}`);
871
855
  }
872
856
  }
873
857
  await saveMeta(meta);
@@ -894,21 +878,29 @@ async function handleReprocess(request) {
894
878
  try {
895
879
  let buffer;
896
880
  const entry = meta[imageKey];
881
+ const isSynced = entry?.s === 1;
897
882
  const originalPath = path7.join(process.cwd(), "public", imageKey);
898
883
  try {
899
- buffer = await fs7.readFile(originalPath);
884
+ buffer = await fs6.readFile(originalPath);
900
885
  } catch {
901
- if (entry?.s) {
886
+ if (isSynced) {
902
887
  buffer = await downloadFromCdn(imageKey);
888
+ const dir = path7.dirname(originalPath);
889
+ await fs6.mkdir(dir, { recursive: true });
890
+ await fs6.writeFile(originalPath, buffer);
903
891
  } else {
904
892
  throw new Error(`File not found: ${imageKey}`);
905
893
  }
906
894
  }
907
895
  const updatedEntry = await processImage(buffer, imageKey);
908
- if (entry?.s) {
896
+ if (isSynced) {
909
897
  updatedEntry.s = 1;
910
898
  await uploadToCdn(imageKey);
911
899
  await deleteLocalThumbnails(imageKey);
900
+ try {
901
+ await fs6.unlink(originalPath);
902
+ } catch {
903
+ }
912
904
  }
913
905
  meta[imageKey] = updatedEntry;
914
906
  processed.push(imageKey);
@@ -942,78 +934,66 @@ async function handleProcessAllStream() {
942
934
  const processed = [];
943
935
  const errors = [];
944
936
  const orphansRemoved = [];
945
- const allImages = [];
946
- async function scanPublicFolder(dir, relativePath = "") {
947
- try {
948
- const entries = await fs7.readdir(dir, { withFileTypes: true });
949
- for (const entry of entries) {
950
- if (entry.name.startsWith(".")) continue;
951
- const fullPath = path7.join(dir, entry.name);
952
- const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
953
- if (relPath === "images" || relPath.startsWith("images/")) continue;
954
- if (entry.isDirectory()) {
955
- await scanPublicFolder(fullPath, relPath);
956
- } else if (isImageFile(entry.name)) {
957
- allImages.push({ key: relPath, fullPath });
958
- }
959
- }
960
- } catch {
937
+ const imagesToProcess = [];
938
+ for (const [key, entry] of Object.entries(meta)) {
939
+ if (entry.s) continue;
940
+ const fileName = path7.basename(key);
941
+ if (!isImageFile(fileName)) continue;
942
+ if (!entry.blur) {
943
+ imagesToProcess.push({ key, entry });
961
944
  }
962
945
  }
963
- const publicDir = path7.join(process.cwd(), "public");
964
- await scanPublicFolder(publicDir);
965
- const total = allImages.length;
946
+ const total = imagesToProcess.length;
966
947
  sendEvent({ type: "start", total });
967
- for (let i = 0; i < allImages.length; i++) {
968
- const { key, fullPath } = allImages[i];
969
- const imageKey = "/" + key;
948
+ for (let i = 0; i < imagesToProcess.length; i++) {
949
+ const { key } = imagesToProcess[i];
950
+ const fullPath = path7.join(process.cwd(), "public", key);
970
951
  sendEvent({
971
952
  type: "progress",
972
953
  current: i + 1,
973
954
  total,
974
955
  percent: Math.round((i + 1) / total * 100),
975
- currentFile: key
956
+ currentFile: key.slice(1)
957
+ // Remove leading /
976
958
  });
977
959
  try {
978
- const buffer = await fs7.readFile(fullPath);
960
+ const buffer = await fs6.readFile(fullPath);
979
961
  const ext = path7.extname(key).toLowerCase();
980
962
  const isSvg = ext === ".svg";
981
963
  if (isSvg) {
982
- const imageDir = path7.dirname(key);
964
+ const imageDir = path7.dirname(key.slice(1));
983
965
  const imagesPath = path7.join(process.cwd(), "public", "images", imageDir === "." ? "" : imageDir);
984
- await fs7.mkdir(imagesPath, { recursive: true });
966
+ await fs6.mkdir(imagesPath, { recursive: true });
985
967
  const fileName = path7.basename(key);
986
968
  const destPath = path7.join(imagesPath, fileName);
987
- await fs7.writeFile(destPath, buffer);
988
- meta[imageKey] = {
969
+ await fs6.writeFile(destPath, buffer);
970
+ meta[key] = {
989
971
  w: 0,
990
972
  h: 0,
991
973
  blur: ""
992
974
  };
993
975
  } else {
994
- const existingEntry = meta[imageKey];
995
- const processedEntry = await processImage(buffer, imageKey);
996
- if (existingEntry?.s) {
997
- processedEntry.s = 1;
998
- }
999
- meta[imageKey] = processedEntry;
976
+ const processedEntry = await processImage(buffer, key);
977
+ meta[key] = processedEntry;
1000
978
  }
1001
- processed.push(key);
979
+ processed.push(key.slice(1));
1002
980
  } catch (error) {
1003
981
  console.error(`Failed to process ${key}:`, error);
1004
- errors.push(key);
982
+ errors.push(key.slice(1));
1005
983
  }
1006
984
  }
1007
985
  sendEvent({ type: "cleanup", message: "Removing orphaned thumbnails..." });
1008
986
  const trackedPaths = /* @__PURE__ */ new Set();
1009
987
  for (const imageKey of Object.keys(meta)) {
1010
- for (const thumbPath of getAllThumbnailPaths(imageKey)) {
1011
- trackedPaths.add(thumbPath);
988
+ if (!meta[imageKey].s) {
989
+ for (const thumbPath of getAllThumbnailPaths(imageKey)) {
990
+ trackedPaths.add(thumbPath);
991
+ }
1012
992
  }
1013
993
  }
1014
994
  async function findOrphans(dir, relativePath = "") {
1015
995
  try {
1016
- const entries = await fs7.readdir(dir, { withFileTypes: true });
996
+ const entries = await fs6.readdir(dir, { withFileTypes: true });
1017
997
  for (const entry of entries) {
1018
998
  if (entry.name.startsWith(".")) continue;
1019
999
  const fullPath = path7.join(dir, entry.name);
@@ -1024,7 +1004,7 @@ async function handleProcessAllStream() {
1024
1004
  const publicPath = `/images/${relPath}`;
1025
1005
  if (!trackedPaths.has(publicPath)) {
1026
1006
  try {
1027
- await fs7.unlink(fullPath);
1007
+ await fs6.unlink(fullPath);
1028
1008
  orphansRemoved.push(publicPath);
1029
1009
  } catch (err) {
1030
1010
  console.error(`Failed to remove orphan ${publicPath}:`, err);
@@ -1036,10 +1016,13 @@ async function handleProcessAllStream() {
1036
1016
  }
1037
1017
  }
1038
1018
  const imagesDir = path7.join(process.cwd(), "public", "images");
1039
- await findOrphans(imagesDir);
1019
+ try {
1020
+ await findOrphans(imagesDir);
1021
+ } catch {
1022
+ }
1040
1023
  async function removeEmptyDirs(dir) {
1041
1024
  try {
1042
- const entries = await fs7.readdir(dir, { withFileTypes: true });
1025
+ const entries = await fs6.readdir(dir, { withFileTypes: true });
1043
1026
  let isEmpty = true;
1044
1027
  for (const entry of entries) {
1045
1028
  if (entry.isDirectory()) {
@@ -1050,14 +1033,17 @@ async function handleProcessAllStream() {
1050
1033
  }
1051
1034
  }
1052
1035
  if (isEmpty && dir !== imagesDir) {
1053
- await fs7.rmdir(dir);
1036
+ await fs6.rmdir(dir);
1054
1037
  }
1055
1038
  return isEmpty;
1056
1039
  } catch {
1057
1040
  return true;
1058
1041
  }
1059
1042
  }
1060
- await removeEmptyDirs(imagesDir);
1043
+ try {
1044
+ await removeEmptyDirs(imagesDir);
1045
+ } catch {
1046
+ }
1061
1047
  await saveMeta(meta);
1062
1048
  sendEvent({
1063
1049
  type: "complete",
@@ -1082,6 +1068,135 @@ async function handleProcessAllStream() {
1082
1068
  });
1083
1069
  }
1084
1070
 
1071
+ // src/handlers/scan.ts
1072
+ import { promises as fs7 } from "fs";
1073
+ import path8 from "path";
1074
+ import sharp3 from "sharp";
1075
+ async function handleScanStream() {
1076
+ const encoder = new TextEncoder();
1077
+ const stream = new ReadableStream({
1078
+ async start(controller) {
1079
+ const sendEvent = (data) => {
1080
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}
1081
+
1082
+ `));
1083
+ };
1084
+ try {
1085
+ const meta = await loadMeta();
1086
+ const existingKeys = new Set(Object.keys(meta));
1087
+ const added = [];
1088
+ const renamed = [];
1089
+ const errors = [];
1090
+ const allFiles = [];
1091
+ async function scanDir(dir, relativePath = "") {
1092
+ try {
1093
+ const entries = await fs7.readdir(dir, { withFileTypes: true });
1094
+ for (const entry of entries) {
1095
+ if (entry.name.startsWith(".")) continue;
1096
+ const fullPath = path8.join(dir, entry.name);
1097
+ const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
1098
+ if (relPath === "images" || relPath.startsWith("images/")) continue;
1099
+ if (entry.isDirectory()) {
1100
+ await scanDir(fullPath, relPath);
1101
+ } else if (isMediaFile(entry.name)) {
1102
+ allFiles.push({ relativePath: relPath, fullPath });
1103
+ }
1104
+ }
1105
+ } catch {
1106
+ }
1107
+ }
1108
+ const publicDir = path8.join(process.cwd(), "public");
1109
+ await scanDir(publicDir);
1110
+ const total = allFiles.length;
1111
+ sendEvent({ type: "start", total });
1112
+ for (let i = 0; i < allFiles.length; i++) {
1113
+ let { relativePath, fullPath } = allFiles[i];
1114
+ let imageKey = "/" + relativePath;
1115
+ sendEvent({
1116
+ type: "progress",
1117
+ current: i + 1,
1118
+ total,
1119
+ percent: Math.round((i + 1) / total * 100),
1120
+ currentFile: relativePath
1121
+ });
1122
+ if (existingKeys.has(imageKey)) {
1123
+ continue;
1124
+ }
1125
+ if (meta[imageKey]) {
1126
+ const ext = path8.extname(relativePath);
1127
+ const baseName = relativePath.slice(0, -ext.length);
1128
+ let counter = 1;
1129
+ let newKey = `/${baseName}-${counter}${ext}`;
1130
+ while (meta[newKey]) {
1131
+ counter++;
1132
+ newKey = `/${baseName}-${counter}${ext}`;
1133
+ }
1134
+ const newRelativePath = `${baseName}-${counter}${ext}`;
1135
+ const newFullPath = path8.join(process.cwd(), "public", newRelativePath);
1136
+ try {
1137
+ await fs7.rename(fullPath, newFullPath);
1138
+ renamed.push({ from: relativePath, to: newRelativePath });
1139
+ relativePath = newRelativePath;
1140
+ fullPath = newFullPath;
1141
+ imageKey = newKey;
1142
+ } catch (err) {
1143
+ console.error(`Failed to rename ${relativePath}:`, err);
1144
+ errors.push(`Failed to rename ${relativePath}`);
1145
+ continue;
1146
+ }
1147
+ }
1148
+ try {
1149
+ const isImage = isImageFile(relativePath);
1150
+ if (isImage) {
1151
+ const ext = path8.extname(relativePath).toLowerCase();
1152
+ if (ext === ".svg") {
1153
+ meta[imageKey] = { w: 0, h: 0 };
1154
+ } else {
1155
+ try {
1156
+ const metadata = await sharp3(fullPath).metadata();
1157
+ meta[imageKey] = {
1158
+ w: metadata.width || 0,
1159
+ h: metadata.height || 0
1160
+ };
1161
+ } catch {
1162
+ meta[imageKey] = { w: 0, h: 0 };
1163
+ }
1164
+ }
1165
+ } else {
1166
+ meta[imageKey] = {};
1167
+ }
1168
+ existingKeys.add(imageKey);
1169
+ added.push(imageKey);
1170
+ } catch (error) {
1171
+ console.error(`Failed to process ${relativePath}:`, error);
1172
+ errors.push(relativePath);
1173
+ }
1174
+ }
1175
+ await saveMeta(meta);
1176
+ sendEvent({
1177
+ type: "complete",
1178
+ added: added.length,
1179
+ renamed: renamed.length,
1180
+ errors: errors.length,
1181
+ renamedFiles: renamed
1182
+ });
1183
+ } catch (error) {
1184
+ console.error("Scan failed:", error);
1185
+ sendEvent({ type: "error", message: "Scan failed" });
1186
+ } finally {
1187
+ controller.close();
1188
+ }
1189
+ }
1190
+ });
1191
+ return new Response(stream, {
1192
+ headers: {
1193
+ "Content-Type": "text/event-stream",
1194
+ "Cache-Control": "no-cache",
1195
+ "Connection": "keep-alive"
1196
+ }
1197
+ });
1198
+ }
1199
+
1085
1200
  // src/handlers/index.ts
1086
1201
  async function GET(request) {
1087
1202
  if (process.env.NODE_ENV !== "development") {
@@ -1136,6 +1251,9 @@ async function POST(request) {
1136
1251
  if (route === "move") {
1137
1252
  return handleMove(request);
1138
1253
  }
1254
+ if (route === "scan") {
1255
+ return handleScanStream();
1256
+ }
1139
1257
  return NextResponse4.json({ error: "Not found" }, { status: 404 });
1140
1258
  }
1141
1259
  async function DELETE(request) {