@gallop.software/studio 0.1.89 → 0.1.90
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/dist/{StudioUI-JQHRTF45.js → StudioUI-HWUO2H6J.js} +48 -43
- package/dist/StudioUI-HWUO2H6J.js.map +1 -0
- package/dist/{StudioUI-T7FA7S7Z.mjs → StudioUI-LWHNOTSN.mjs} +53 -48
- package/dist/StudioUI-LWHNOTSN.mjs.map +1 -0
- package/dist/{chunk-HE2DOD2K.js → chunk-JWAAU3NN.js} +1 -1
- package/dist/chunk-JWAAU3NN.js.map +1 -0
- package/dist/{chunk-QR2FRA4L.mjs → chunk-ZGXOYJKZ.mjs} +1 -1
- package/dist/chunk-ZGXOYJKZ.mjs.map +1 -0
- package/dist/handlers/index.d.mts +1 -1
- package/dist/handlers/index.d.ts +1 -1
- package/dist/handlers/index.js +29 -29
- package/dist/handlers/index.js.map +1 -1
- package/dist/handlers/index.mjs +16 -16
- package/dist/handlers/index.mjs.map +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +3 -3
- package/dist/index.mjs +2 -2
- package/dist/{types-Cxqb0WUK.d.mts → types-DzM_J-55.d.mts} +4 -4
- package/dist/{types-Cxqb0WUK.d.ts → types-DzM_J-55.d.ts} +4 -4
- package/package.json +1 -1
- package/dist/StudioUI-JQHRTF45.js.map +0 -1
- package/dist/StudioUI-T7FA7S7Z.mjs.map +0 -1
- package/dist/chunk-HE2DOD2K.js.map +0 -1
- package/dist/chunk-QR2FRA4L.mjs.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["/Users/chrisb/Sites/studio/dist/chunk-JWAAU3NN.js","../src/types.ts"],"names":["ext","base","outputExt"],"mappings":"AAAA;AC2DO,SAAS,gBAAA,CAAiB,YAAA,EAAsB,IAAA,EAA2C;AAChG,EAAA,GAAA,CAAI,KAAA,IAAS,MAAA,EAAQ;AACnB,IAAA,MAAMA,KAAAA,kBAAM,YAAA,mBAAa,KAAA,mBAAM,QAAQ,CAAA,4BAAA,CAAI,CAAC,IAAA,GAAK,MAAA;AACjD,IAAA,MAAMC,MAAAA,EAAO,YAAA,CAAa,OAAA,CAAQ,QAAA,EAAU,EAAE,CAAA;AAC9C,IAAA,MAAMC,WAAAA,EAAYF,IAAAA,CAAI,WAAA,CAAY,EAAA,IAAM,OAAA,EAAS,OAAA,EAAS,MAAA;AAC1D,IAAA,OAAO,CAAA,OAAA,EAAUC,KAAI,CAAA,EAAA;AACvB,EAAA;AACyB,EAAA;AACZ,EAAA;AACS,EAAA;AACG,EAAA;AAC3B;AAKgB;AACP,EAAA;AACY,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACnB,EAAA;AACF;AD7D2B;AACA;AACA;AACA;AACA","file":"/Users/chrisb/Sites/studio/dist/chunk-JWAAU3NN.js","sourcesContent":[null,"/**\n * Meta entry - works for images and non-images\n * Images have w, h, b (after processing)\n * Non-images just have c (if pushed to CDN)\n */\nexport interface MetaEntry {\n w?: number // original width (images only)\n h?: number // original height (images only)\n b?: string // blurhash (images only, after processing)\n c?: 1 // pushed to CDN (omit if not pushed)\n}\n\n/**\n * Meta schema - keyed by path from public folder\n * Example: { \"/portfolio/photo.jpg\": { w: 2400, h: 1600, blur: \"...\" } }\n */\nexport type LeanMeta = Record<string, MetaEntry>\n\n// Legacy alias for compatibility\nexport type LeanImageEntry = MetaEntry\n\n/**\n * File/folder item for browser\n */\nexport interface FileItem {\n name: string\n path: string\n type: 'file' | 'folder'\n size?: number\n dimensions?: { width: number; height: number }\n cdnSynced?: boolean\n // Folder-specific properties\n fileCount?: number\n totalSize?: number\n // For showing thumbnails - path to -sm version if exists\n thumbnail?: string\n // Whether a processed thumbnail exists\n hasThumbnail?: boolean\n}\n\n/**\n * Studio configuration\n */\nexport interface StudioConfig {\n r2AccountId?: string\n r2AccessKeyId?: string\n r2SecretAccessKey?: string\n r2BucketName?: string\n r2PublicUrl?: string\n thumbnailSizes?: {\n small: number\n medium: number\n large: number\n }\n}\n\n/**\n * Get thumbnail path from original image path\n */\nexport function getThumbnailPath(originalPath: string, size: 'sm' | 'md' | 'lg' | 'full'): string {\n if (size === 'full') {\n const ext = originalPath.match(/\\.\\w+$/)?.[0] || '.jpg'\n const base = originalPath.replace(/\\.\\w+$/, '')\n const outputExt = ext.toLowerCase() === '.png' ? '.png' : '.jpg'\n return `/images${base}${outputExt}`\n }\n const ext = originalPath.match(/\\.\\w+$/)?.[0] || '.jpg'\n const base = originalPath.replace(/\\.\\w+$/, '')\n const outputExt = ext.toLowerCase() === '.png' ? '.png' : '.jpg'\n return `/images${base}-${size}${outputExt}`\n}\n\n/**\n * Get all thumbnail paths for an image\n */\nexport function getAllThumbnailPaths(originalPath: string): string[] {\n return [\n getThumbnailPath(originalPath, 'full'),\n getThumbnailPath(originalPath, 'lg'),\n getThumbnailPath(originalPath, 'md'),\n getThumbnailPath(originalPath, 'sm'),\n ]\n}\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/types.ts"],"sourcesContent":["/**\n * Meta entry - works for images and non-images\n * Images have w, h, b (after processing)\n * Non-images just have c (if pushed to CDN)\n */\nexport interface MetaEntry {\n w?: number // original width (images only)\n h?: number // original height (images only)\n b?: string // blurhash (images only, after processing)\n c?: 1 // pushed to CDN (omit if not pushed)\n}\n\n/**\n * Meta schema - keyed by path from public folder\n * Example: { \"/portfolio/photo.jpg\": { w: 2400, h: 1600, blur: \"...\" } }\n */\nexport type LeanMeta = Record<string, MetaEntry>\n\n// Legacy alias for compatibility\nexport type LeanImageEntry = MetaEntry\n\n/**\n * File/folder item for browser\n */\nexport interface FileItem {\n name: string\n path: string\n type: 'file' | 'folder'\n size?: number\n dimensions?: { width: number; height: number }\n cdnSynced?: boolean\n // Folder-specific properties\n fileCount?: number\n totalSize?: number\n // For showing thumbnails - path to -sm version if exists\n thumbnail?: string\n // Whether a processed thumbnail exists\n hasThumbnail?: boolean\n}\n\n/**\n * Studio configuration\n */\nexport interface StudioConfig {\n r2AccountId?: string\n r2AccessKeyId?: string\n r2SecretAccessKey?: string\n r2BucketName?: string\n r2PublicUrl?: string\n thumbnailSizes?: {\n small: number\n medium: number\n large: number\n }\n}\n\n/**\n * Get thumbnail path from original image path\n */\nexport function getThumbnailPath(originalPath: string, size: 'sm' | 'md' | 'lg' | 'full'): string {\n if (size === 'full') {\n const ext = originalPath.match(/\\.\\w+$/)?.[0] || '.jpg'\n const base = originalPath.replace(/\\.\\w+$/, '')\n const outputExt = ext.toLowerCase() === '.png' ? '.png' : '.jpg'\n return `/images${base}${outputExt}`\n }\n const ext = originalPath.match(/\\.\\w+$/)?.[0] || '.jpg'\n const base = originalPath.replace(/\\.\\w+$/, '')\n const outputExt = ext.toLowerCase() === '.png' ? '.png' : '.jpg'\n return `/images${base}-${size}${outputExt}`\n}\n\n/**\n * Get all thumbnail paths for an image\n */\nexport function getAllThumbnailPaths(originalPath: string): string[] {\n return [\n getThumbnailPath(originalPath, 'full'),\n getThumbnailPath(originalPath, 'lg'),\n getThumbnailPath(originalPath, 'md'),\n getThumbnailPath(originalPath, 'sm'),\n ]\n}\n"],"mappings":";AA2DO,SAAS,iBAAiB,cAAsB,MAA2C;AAChG,MAAI,SAAS,QAAQ;AACnB,UAAMA,OAAM,aAAa,MAAM,QAAQ,IAAI,CAAC,KAAK;AACjD,UAAMC,QAAO,aAAa,QAAQ,UAAU,EAAE;AAC9C,UAAMC,aAAYF,KAAI,YAAY,MAAM,SAAS,SAAS;AAC1D,WAAO,UAAUC,KAAI,GAAGC,UAAS;AAAA,EACnC;AACA,QAAM,MAAM,aAAa,MAAM,QAAQ,IAAI,CAAC,KAAK;AACjD,QAAM,OAAO,aAAa,QAAQ,UAAU,EAAE;AAC9C,QAAM,YAAY,IAAI,YAAY,MAAM,SAAS,SAAS;AAC1D,SAAO,UAAU,IAAI,IAAI,IAAI,GAAG,SAAS;AAC3C;AAKO,SAAS,qBAAqB,cAAgC;AACnE,SAAO;AAAA,IACL,iBAAiB,cAAc,MAAM;AAAA,IACrC,iBAAiB,cAAc,IAAI;AAAA,IACnC,iBAAiB,cAAc,IAAI;AAAA,IACnC,iBAAiB,cAAc,IAAI;AAAA,EACrC;AACF;","names":["ext","base","outputExt"]}
|
package/dist/handlers/index.d.ts
CHANGED
package/dist/handlers/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; }
|
|
2
2
|
|
|
3
3
|
|
|
4
|
-
var
|
|
4
|
+
var _chunkJWAAU3NNjs = require('../chunk-JWAAU3NN.js');
|
|
5
5
|
|
|
6
6
|
// src/handlers/index.ts
|
|
7
7
|
var _server = require('next/server');
|
|
@@ -114,7 +114,7 @@ async function processImage(buffer, imageKey) {
|
|
|
114
114
|
return {
|
|
115
115
|
w: originalWidth,
|
|
116
116
|
h: originalHeight,
|
|
117
|
-
|
|
117
|
+
b: blurhash
|
|
118
118
|
};
|
|
119
119
|
}
|
|
120
120
|
|
|
@@ -156,7 +156,7 @@ async function uploadToCdn(imageKey) {
|
|
|
156
156
|
const bucketName = process.env.CLOUDFLARE_R2_BUCKET_NAME;
|
|
157
157
|
if (!bucketName) throw new Error("R2 bucket not configured");
|
|
158
158
|
const r2 = getR2Client();
|
|
159
|
-
for (const thumbPath of
|
|
159
|
+
for (const thumbPath of _chunkJWAAU3NNjs.getAllThumbnailPaths.call(void 0, imageKey)) {
|
|
160
160
|
const localPath = _path2.default.join(process.cwd(), "public", thumbPath);
|
|
161
161
|
try {
|
|
162
162
|
const fileBuffer = await _fs.promises.readFile(localPath);
|
|
@@ -173,7 +173,7 @@ async function uploadToCdn(imageKey) {
|
|
|
173
173
|
}
|
|
174
174
|
}
|
|
175
175
|
async function deleteLocalThumbnails(imageKey) {
|
|
176
|
-
for (const thumbPath of
|
|
176
|
+
for (const thumbPath of _chunkJWAAU3NNjs.getAllThumbnailPaths.call(void 0, imageKey)) {
|
|
177
177
|
const localPath = _path2.default.join(process.cwd(), "public", thumbPath);
|
|
178
178
|
try {
|
|
179
179
|
await _fs.promises.unlink(localPath);
|
|
@@ -222,12 +222,12 @@ async function handleList(request) {
|
|
|
222
222
|
} else {
|
|
223
223
|
const fileName = remaining;
|
|
224
224
|
const isImage = isImageFile(fileName);
|
|
225
|
-
const isSynced = entry.
|
|
225
|
+
const isSynced = entry.c === 1;
|
|
226
226
|
let thumbnail;
|
|
227
227
|
let hasThumbnail = false;
|
|
228
228
|
let fileSize;
|
|
229
|
-
if (isImage && (entry.w || entry.
|
|
230
|
-
const thumbPath =
|
|
229
|
+
if (isImage && (entry.w || entry.b)) {
|
|
230
|
+
const thumbPath = _chunkJWAAU3NNjs.getThumbnailPath.call(void 0, key, "sm");
|
|
231
231
|
if (isSynced) {
|
|
232
232
|
const cdnUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL || process.env.NEXT_PUBLIC_CLOUDFLARE_R2_PUBLIC_URL;
|
|
233
233
|
if (cdnUrl) {
|
|
@@ -289,11 +289,11 @@ async function handleSearch(request) {
|
|
|
289
289
|
const fileName = _path2.default.basename(key);
|
|
290
290
|
const relativePath = key.slice(1);
|
|
291
291
|
const isImage = isImageFile(fileName);
|
|
292
|
-
const isSynced = entry.
|
|
292
|
+
const isSynced = entry.c === 1;
|
|
293
293
|
let thumbnail;
|
|
294
294
|
let hasThumbnail = false;
|
|
295
|
-
if (isImage && (entry.w || entry.
|
|
296
|
-
const thumbPath =
|
|
295
|
+
if (isImage && (entry.w || entry.b)) {
|
|
296
|
+
const thumbPath = _chunkJWAAU3NNjs.getThumbnailPath.call(void 0, key, "sm");
|
|
297
297
|
if (isSynced) {
|
|
298
298
|
const cdnUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL || process.env.NEXT_PUBLIC_CLOUDFLARE_R2_PUBLIC_URL;
|
|
299
299
|
if (cdnUrl) {
|
|
@@ -513,7 +513,7 @@ async function handleDelete(request) {
|
|
|
513
513
|
const absolutePath = _path2.default.join(process.cwd(), itemPath);
|
|
514
514
|
const imageKey = "/" + itemPath.replace(/^public\//, "");
|
|
515
515
|
const entry = meta[imageKey];
|
|
516
|
-
const isSynced = _optionalChain([entry, 'optionalAccess', _5 => _5.
|
|
516
|
+
const isSynced = _optionalChain([entry, 'optionalAccess', _5 => _5.c]) === 1;
|
|
517
517
|
try {
|
|
518
518
|
const stats = await _fs.promises.stat(absolutePath);
|
|
519
519
|
if (stats.isDirectory()) {
|
|
@@ -521,8 +521,8 @@ async function handleDelete(request) {
|
|
|
521
521
|
const prefix = imageKey + "/";
|
|
522
522
|
for (const key of Object.keys(meta)) {
|
|
523
523
|
if (key.startsWith(prefix) || key === imageKey) {
|
|
524
|
-
if (!meta[key].
|
|
525
|
-
for (const thumbPath of
|
|
524
|
+
if (!meta[key].c) {
|
|
525
|
+
for (const thumbPath of _chunkJWAAU3NNjs.getAllThumbnailPaths.call(void 0, key)) {
|
|
526
526
|
const absoluteThumbPath = _path2.default.join(process.cwd(), "public", thumbPath);
|
|
527
527
|
try {
|
|
528
528
|
await _fs.promises.unlink(absoluteThumbPath);
|
|
@@ -538,7 +538,7 @@ async function handleDelete(request) {
|
|
|
538
538
|
const isInImagesFolder = itemPath.startsWith("public/images/");
|
|
539
539
|
if (!isInImagesFolder && entry) {
|
|
540
540
|
if (!isSynced) {
|
|
541
|
-
for (const thumbPath of
|
|
541
|
+
for (const thumbPath of _chunkJWAAU3NNjs.getAllThumbnailPaths.call(void 0, imageKey)) {
|
|
542
542
|
const absoluteThumbPath = _path2.default.join(process.cwd(), "public", thumbPath);
|
|
543
543
|
try {
|
|
544
544
|
await _fs.promises.unlink(absoluteThumbPath);
|
|
@@ -650,8 +650,8 @@ async function handleRename(request) {
|
|
|
650
650
|
const newKey = "/" + newRelativePath;
|
|
651
651
|
if (meta[oldKey]) {
|
|
652
652
|
const entry = meta[oldKey];
|
|
653
|
-
const oldThumbPaths =
|
|
654
|
-
const newThumbPaths =
|
|
653
|
+
const oldThumbPaths = _chunkJWAAU3NNjs.getAllThumbnailPaths.call(void 0, oldKey);
|
|
654
|
+
const newThumbPaths = _chunkJWAAU3NNjs.getAllThumbnailPaths.call(void 0, newKey);
|
|
655
655
|
for (let i = 0; i < oldThumbPaths.length; i++) {
|
|
656
656
|
const oldThumbPath = _path2.default.join(process.cwd(), "public", oldThumbPaths[i]);
|
|
657
657
|
const newThumbPath = _path2.default.join(process.cwd(), "public", newThumbPaths[i]);
|
|
@@ -730,8 +730,8 @@ async function handleMove(request) {
|
|
|
730
730
|
const newKey = "/" + newRelativePath;
|
|
731
731
|
if (meta[oldKey]) {
|
|
732
732
|
const entry = meta[oldKey];
|
|
733
|
-
const oldThumbPaths =
|
|
734
|
-
const newThumbPaths =
|
|
733
|
+
const oldThumbPaths = _chunkJWAAU3NNjs.getAllThumbnailPaths.call(void 0, oldKey);
|
|
734
|
+
const newThumbPaths = _chunkJWAAU3NNjs.getAllThumbnailPaths.call(void 0, newKey);
|
|
735
735
|
for (let i = 0; i < oldThumbPaths.length; i++) {
|
|
736
736
|
const oldThumbPath = _path2.default.join(process.cwd(), "public", oldThumbPaths[i]);
|
|
737
737
|
const newThumbPath = _path2.default.join(process.cwd(), "public", newThumbPaths[i]);
|
|
@@ -801,7 +801,7 @@ async function handleSync(request) {
|
|
|
801
801
|
errors.push(`Image not found in meta: ${imageKey}. Run Scan first.`);
|
|
802
802
|
continue;
|
|
803
803
|
}
|
|
804
|
-
if (entry.
|
|
804
|
+
if (entry.c) {
|
|
805
805
|
synced.push(imageKey);
|
|
806
806
|
continue;
|
|
807
807
|
}
|
|
@@ -821,7 +821,7 @@ async function handleSync(request) {
|
|
|
821
821
|
errors.push(`Original file not found: ${imageKey}`);
|
|
822
822
|
continue;
|
|
823
823
|
}
|
|
824
|
-
for (const thumbPath of
|
|
824
|
+
for (const thumbPath of _chunkJWAAU3NNjs.getAllThumbnailPaths.call(void 0, imageKey)) {
|
|
825
825
|
const localPath = _path2.default.join(process.cwd(), "public", thumbPath);
|
|
826
826
|
try {
|
|
827
827
|
const fileBuffer = await _fs.promises.readFile(localPath);
|
|
@@ -836,8 +836,8 @@ async function handleSync(request) {
|
|
|
836
836
|
} catch (e20) {
|
|
837
837
|
}
|
|
838
838
|
}
|
|
839
|
-
entry.
|
|
840
|
-
for (const thumbPath of
|
|
839
|
+
entry.c = 1;
|
|
840
|
+
for (const thumbPath of _chunkJWAAU3NNjs.getAllThumbnailPaths.call(void 0, imageKey)) {
|
|
841
841
|
const localPath = _path2.default.join(process.cwd(), "public", thumbPath);
|
|
842
842
|
try {
|
|
843
843
|
await _fs.promises.unlink(localPath);
|
|
@@ -878,7 +878,7 @@ async function handleReprocess(request) {
|
|
|
878
878
|
try {
|
|
879
879
|
let buffer;
|
|
880
880
|
const entry = meta[imageKey];
|
|
881
|
-
const isSynced = _optionalChain([entry, 'optionalAccess', _6 => _6.
|
|
881
|
+
const isSynced = _optionalChain([entry, 'optionalAccess', _6 => _6.c]) === 1;
|
|
882
882
|
const originalPath = _path2.default.join(process.cwd(), "public", imageKey);
|
|
883
883
|
try {
|
|
884
884
|
buffer = await _fs.promises.readFile(originalPath);
|
|
@@ -894,7 +894,7 @@ async function handleReprocess(request) {
|
|
|
894
894
|
}
|
|
895
895
|
const updatedEntry = await processImage(buffer, imageKey);
|
|
896
896
|
if (isSynced) {
|
|
897
|
-
updatedEntry.
|
|
897
|
+
updatedEntry.c = 1;
|
|
898
898
|
await uploadToCdn(imageKey);
|
|
899
899
|
await deleteLocalThumbnails(imageKey);
|
|
900
900
|
try {
|
|
@@ -936,10 +936,10 @@ async function handleProcessAllStream() {
|
|
|
936
936
|
const orphansRemoved = [];
|
|
937
937
|
const imagesToProcess = [];
|
|
938
938
|
for (const [key, entry] of Object.entries(meta)) {
|
|
939
|
-
if (entry.
|
|
939
|
+
if (entry.c) continue;
|
|
940
940
|
const fileName = _path2.default.basename(key);
|
|
941
941
|
if (!isImageFile(fileName)) continue;
|
|
942
|
-
if (!entry.
|
|
942
|
+
if (!entry.b) {
|
|
943
943
|
imagesToProcess.push({ key, entry });
|
|
944
944
|
}
|
|
945
945
|
}
|
|
@@ -970,7 +970,7 @@ async function handleProcessAllStream() {
|
|
|
970
970
|
meta[key] = {
|
|
971
971
|
w: 0,
|
|
972
972
|
h: 0,
|
|
973
|
-
|
|
973
|
+
b: ""
|
|
974
974
|
};
|
|
975
975
|
} else {
|
|
976
976
|
const processedEntry = await processImage(buffer, key);
|
|
@@ -985,8 +985,8 @@ async function handleProcessAllStream() {
|
|
|
985
985
|
sendEvent({ type: "cleanup", message: "Removing orphaned thumbnails..." });
|
|
986
986
|
const trackedPaths = /* @__PURE__ */ new Set();
|
|
987
987
|
for (const imageKey of Object.keys(meta)) {
|
|
988
|
-
if (!meta[imageKey].
|
|
989
|
-
for (const thumbPath of
|
|
988
|
+
if (!meta[imageKey].c) {
|
|
989
|
+
for (const thumbPath of _chunkJWAAU3NNjs.getAllThumbnailPaths.call(void 0, imageKey)) {
|
|
990
990
|
trackedPaths.add(thumbPath);
|
|
991
991
|
}
|
|
992
992
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["/Users/chrisb/Sites/studio/dist/handlers/index.js","../../src/handlers/index.ts","../../src/handlers/list.ts","../../src/handlers/utils/meta.ts","../../src/handlers/utils/files.ts","../../src/handlers/utils/thumbnails.ts","../../src/handlers/utils/cdn.ts","../../src/handlers/files.ts","../../src/handlers/images.ts","../../src/handlers/scan.ts"],"names":["path","fs","NextResponse","sharp"],"mappings":"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","file":"/Users/chrisb/Sites/studio/dist/handlers/index.js","sourcesContent":[null,"import { NextRequest, NextResponse } from 'next/server'\n\n// List handlers\nimport { handleList, handleSearch, handleListFolders, handleCountImages, handleFolderImages } from './list'\n\n// File handlers\nimport { handleUpload, handleDelete, handleCreateFolder, handleRename, handleMove } from './files'\n\n// Image handlers\nimport { handleSync, handleReprocess, handleProcessAllStream } from './images'\n\n// Scan handler\nimport { handleScanStream } from './scan'\n\n/**\n * Unified GET handler for all Studio API routes\n */\nexport async function GET(request: NextRequest) {\n if (process.env.NODE_ENV !== 'development') {\n return NextResponse.json({ error: 'Not available in production' }, { status: 403 })\n }\n\n const pathname = request.nextUrl.pathname\n const route = pathname.replace(/^\\/api\\/studio\\/?/, '')\n\n // Route: /api/studio/list-folders (must come before 'list' check)\n if (route === 'list-folders') {\n return handleListFolders()\n }\n\n // Route: /api/studio/list\n if (route === 'list' || route.startsWith('list')) {\n return handleList(request)\n }\n\n // Route: /api/studio/count-images\n if (route === 'count-images') {\n return handleCountImages()\n }\n\n // Route: /api/studio/folder-images\n if (route === 'folder-images') {\n return handleFolderImages(request)\n }\n\n // Route: /api/studio/search\n if (route === 'search') {\n return handleSearch(request)\n }\n\n return NextResponse.json({ error: 'Not found' }, { status: 404 })\n}\n\n/**\n * Unified POST handler for all Studio API routes\n */\nexport async function POST(request: NextRequest) {\n if (process.env.NODE_ENV !== 'development') {\n return NextResponse.json({ error: 'Not available in production' }, { status: 403 })\n }\n\n const pathname = request.nextUrl.pathname\n const route = pathname.replace(/^\\/api\\/studio\\/?/, '')\n\n // Route: /api/studio/upload\n if (route === 'upload') {\n return handleUpload(request)\n }\n\n // Route: /api/studio/delete\n if (route === 'delete') {\n return handleDelete(request)\n }\n\n // Route: /api/studio/sync\n if (route === 'sync') {\n return handleSync(request)\n }\n\n // Route: /api/studio/reprocess\n if (route === 'reprocess') {\n return handleReprocess(request)\n }\n\n // Route: /api/studio/process-all (streaming)\n if (route === 'process-all') {\n return handleProcessAllStream()\n }\n\n // Route: /api/studio/create-folder\n if (route === 'create-folder') {\n return handleCreateFolder(request)\n }\n\n // Route: /api/studio/rename\n if (route === 'rename') {\n return handleRename(request)\n }\n\n // Route: /api/studio/move\n if (route === 'move') {\n return handleMove(request)\n }\n\n // Route: /api/studio/scan (streaming)\n if (route === 'scan') {\n return handleScanStream()\n }\n\n return NextResponse.json({ error: 'Not found' }, { status: 404 })\n}\n\n/**\n * Unified DELETE handler\n */\nexport async function DELETE(request: NextRequest) {\n if (process.env.NODE_ENV !== 'development') {\n return NextResponse.json({ error: 'Not available in production' }, { status: 403 })\n }\n\n return handleDelete(request)\n}\n","import { NextRequest, NextResponse } from 'next/server'\nimport { promises as fs } from 'fs'\nimport path from 'path'\nimport type { FileItem } from '../types'\nimport { loadMeta, isImageFile } from './utils'\nimport { getThumbnailPath } from '../types'\n\n/**\n * List files and folders from meta\n * Folders are derived from file paths in meta\n */\nexport async function handleList(request: NextRequest) {\n const searchParams = request.nextUrl.searchParams\n const requestedPath = searchParams.get('path') || 'public'\n\n try {\n const meta = await loadMeta()\n const metaKeys = Object.keys(meta)\n \n // If meta is empty, return empty with a flag\n if (metaKeys.length === 0) {\n return NextResponse.json({ items: [], isEmpty: true })\n }\n\n // Normalize the requested path to match meta keys\n // requestedPath is like \"public\" or \"public/photos\"\n // meta keys are like \"/photos/image.jpg\"\n const relativePath = requestedPath.replace(/^public\\/?/, '')\n const pathPrefix = relativePath ? `/${relativePath}/` : '/'\n\n const items: FileItem[] = []\n const seenFolders = new Set<string>()\n\n for (const key of metaKeys) {\n const entry = meta[key]\n \n // Check if this file is under the current path\n if (!key.startsWith(pathPrefix) && pathPrefix !== '/') continue\n if (pathPrefix === '/' && !key.startsWith('/')) continue\n\n // Get the part after the current path\n const remaining = pathPrefix === '/' ? key.slice(1) : key.slice(pathPrefix.length)\n \n // Skip if empty (shouldn't happen)\n if (!remaining) continue\n\n // Check if there's a subfolder\n const slashIndex = remaining.indexOf('/')\n \n if (slashIndex !== -1) {\n // This is in a subfolder - show the folder\n const folderName = remaining.slice(0, slashIndex)\n \n if (!seenFolders.has(folderName)) {\n seenFolders.add(folderName)\n \n // Count files in this folder from meta\n const folderPrefix = pathPrefix === '/' ? `/${folderName}/` : `${pathPrefix}${folderName}/`\n let fileCount = 0\n for (const k of metaKeys) {\n if (k.startsWith(folderPrefix)) fileCount++\n }\n \n items.push({\n name: folderName,\n path: relativePath ? `public/${relativePath}/${folderName}` : `public/${folderName}`,\n type: 'folder',\n fileCount,\n })\n }\n } else {\n // This is a file in the current folder\n const fileName = remaining\n const isImage = isImageFile(fileName)\n const isSynced = entry.s === 1\n \n let thumbnail: string | undefined\n let hasThumbnail = false\n let fileSize: number | undefined\n \n if (isImage && (entry.w || entry.blur)) {\n // Has been processed - use thumbnail\n const thumbPath = getThumbnailPath(key, 'sm')\n \n if (isSynced) {\n // CDN thumbnail\n const cdnUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL || process.env.NEXT_PUBLIC_CLOUDFLARE_R2_PUBLIC_URL\n if (cdnUrl) {\n thumbnail = `${cdnUrl}${thumbPath}`\n hasThumbnail = true\n }\n } else {\n // Local thumbnail - check if exists\n const localThumbPath = path.join(process.cwd(), 'public', thumbPath)\n try {\n await fs.access(localThumbPath)\n thumbnail = thumbPath\n hasThumbnail = true\n } catch {\n // Thumbnail doesn't exist yet\n thumbnail = key\n hasThumbnail = false\n }\n }\n } else if (isImage) {\n // Not processed yet - use original\n thumbnail = key\n hasThumbnail = false\n }\n \n // Try to get file size if file exists locally\n if (!isSynced) {\n try {\n const filePath = path.join(process.cwd(), 'public', key)\n const stats = await fs.stat(filePath)\n fileSize = stats.size\n } catch {\n // File might not exist locally (synced)\n }\n }\n \n items.push({\n name: fileName,\n path: relativePath ? `public/${relativePath}/${fileName}` : `public/${fileName}`,\n type: 'file',\n size: fileSize,\n thumbnail,\n hasThumbnail,\n cdnSynced: isSynced,\n dimensions: entry.w && entry.h ? { width: entry.w, height: entry.h } : undefined,\n })\n }\n }\n\n return NextResponse.json({ items })\n } catch (error) {\n console.error('Failed to list directory:', error)\n return NextResponse.json({ error: 'Failed to list directory' }, { status: 500 })\n }\n}\n\nexport async function handleSearch(request: NextRequest) {\n const searchParams = request.nextUrl.searchParams\n const query = searchParams.get('q')?.toLowerCase() || ''\n \n if (query.length < 2) {\n return NextResponse.json({ items: [] })\n }\n\n try {\n const meta = await loadMeta()\n const items: FileItem[] = []\n\n for (const [key, entry] of Object.entries(meta)) {\n // Check if the path matches the query\n if (!key.toLowerCase().includes(query)) continue\n \n const fileName = path.basename(key)\n const relativePath = key.slice(1) // Remove leading /\n const isImage = isImageFile(fileName)\n const isSynced = entry.s === 1\n \n let thumbnail: string | undefined\n let hasThumbnail = false\n \n if (isImage && (entry.w || entry.blur)) {\n const thumbPath = getThumbnailPath(key, 'sm')\n \n if (isSynced) {\n const cdnUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL || process.env.NEXT_PUBLIC_CLOUDFLARE_R2_PUBLIC_URL\n if (cdnUrl) {\n thumbnail = `${cdnUrl}${thumbPath}`\n hasThumbnail = true\n }\n } else {\n const localThumbPath = path.join(process.cwd(), 'public', thumbPath)\n try {\n await fs.access(localThumbPath)\n thumbnail = thumbPath\n hasThumbnail = true\n } catch {\n thumbnail = key\n hasThumbnail = false\n }\n }\n } else if (isImage) {\n thumbnail = key\n hasThumbnail = false\n }\n \n items.push({\n name: fileName,\n path: `public/${relativePath}`,\n type: 'file',\n thumbnail,\n hasThumbnail,\n cdnSynced: isSynced,\n dimensions: entry.w && entry.h ? { width: entry.w, height: entry.h } : undefined,\n })\n }\n\n return NextResponse.json({ items })\n } catch (error) {\n console.error('Failed to search:', error)\n return NextResponse.json({ error: 'Failed to search' }, { status: 500 })\n }\n}\n\nexport async function handleListFolders() {\n try {\n const meta = await loadMeta()\n const folderSet = new Set<string>()\n \n // Extract all folder paths from meta keys\n for (const key of Object.keys(meta)) {\n const parts = key.split('/')\n // Build up folder paths: /photos/2024/image.jpg -> photos, photos/2024\n let current = ''\n for (let i = 1; i < parts.length - 1; i++) {\n current = current ? `${current}/${parts[i]}` : parts[i]\n folderSet.add(current)\n }\n }\n \n const folders: { path: string; name: string; depth: number }[] = []\n folders.push({ path: 'public', name: 'public', depth: 0 })\n \n const sortedFolders = Array.from(folderSet).sort()\n for (const folderPath of sortedFolders) {\n const depth = folderPath.split('/').length\n const name = folderPath.split('/').pop() || folderPath\n folders.push({\n path: `public/${folderPath}`,\n name,\n depth\n })\n }\n\n return NextResponse.json({ folders })\n } catch (error) {\n console.error('Failed to list folders:', error)\n return NextResponse.json({ error: 'Failed to list folders' }, { status: 500 })\n }\n}\n\nexport async function handleCountImages() {\n try {\n const meta = await loadMeta()\n const allImages: string[] = []\n\n for (const key of Object.keys(meta)) {\n const fileName = path.basename(key)\n if (isImageFile(fileName)) {\n allImages.push(key.slice(1)) // Remove leading /\n }\n }\n\n return NextResponse.json({\n count: allImages.length,\n images: allImages,\n })\n } catch (error) {\n console.error('Failed to count images:', error)\n return NextResponse.json({ error: 'Failed to count images' }, { status: 500 })\n }\n}\n\nexport async function handleFolderImages(request: NextRequest) {\n try {\n const searchParams = request.nextUrl.searchParams\n const foldersParam = searchParams.get('folders')\n \n if (!foldersParam) {\n return NextResponse.json({ error: 'No folders provided' }, { status: 400 })\n }\n\n const folders = foldersParam.split(',')\n const meta = await loadMeta()\n const allImages: string[] = []\n\n // Convert folder paths to prefixes for matching\n const prefixes = folders.map(f => {\n const rel = f.replace(/^public\\/?/, '')\n return rel ? `/${rel}/` : '/'\n })\n\n for (const key of Object.keys(meta)) {\n const fileName = path.basename(key)\n if (!isImageFile(fileName)) continue\n \n // Check if this image is in one of the requested folders\n for (const prefix of prefixes) {\n if (key.startsWith(prefix) || (prefix === '/' && key.startsWith('/'))) {\n allImages.push(key.slice(1)) // Remove leading /\n break\n }\n }\n }\n\n return NextResponse.json({\n count: allImages.length,\n images: allImages,\n })\n } catch (error) {\n console.error('Failed to get folder images:', error)\n return NextResponse.json({ error: 'Failed to get folder images' }, { status: 500 })\n }\n}\n","import { promises as fs } from 'fs'\nimport path from 'path'\nimport type { LeanMeta } from '../../types'\n\nexport async function loadMeta(): Promise<LeanMeta> {\n const metaPath = path.join(process.cwd(), '_data', '_meta.json')\n \n try {\n const content = await fs.readFile(metaPath, 'utf-8')\n return JSON.parse(content) as LeanMeta\n } catch {\n return {}\n }\n}\n\nexport async function saveMeta(meta: LeanMeta): Promise<void> {\n const dataDir = path.join(process.cwd(), '_data')\n await fs.mkdir(dataDir, { recursive: true })\n const metaPath = path.join(dataDir, '_meta.json')\n await fs.writeFile(metaPath, JSON.stringify(meta, null, 2))\n}\n","import { promises as fs } from 'fs'\nimport path from 'path'\n\nexport function isImageFile(filename: string): boolean {\n const ext = path.extname(filename).toLowerCase()\n return ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.ico', '.bmp', '.tiff', '.tif'].includes(ext)\n}\n\nexport function isMediaFile(filename: string): boolean {\n const ext = path.extname(filename).toLowerCase()\n // Images\n if (['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.ico', '.bmp', '.tiff', '.tif'].includes(ext)) return true\n // Videos\n if (['.mp4', '.webm', '.mov', '.avi', '.mkv', '.m4v'].includes(ext)) return true\n // Audio\n if (['.mp3', '.wav', '.ogg', '.m4a', '.flac', '.aac'].includes(ext)) return true\n // Documents/PDFs\n if (['.pdf'].includes(ext)) return true\n return false\n}\n\nexport function getContentType(filePath: string): string {\n const ext = path.extname(filePath).toLowerCase()\n switch (ext) {\n case '.jpg':\n case '.jpeg':\n return 'image/jpeg'\n case '.png':\n return 'image/png'\n case '.gif':\n return 'image/gif'\n case '.webp':\n return 'image/webp'\n case '.svg':\n return 'image/svg+xml'\n default:\n return 'application/octet-stream'\n }\n}\n\nexport async function getFolderStats(folderPath: string): Promise<{ fileCount: number; totalSize: number }> {\n let fileCount = 0\n let totalSize = 0\n\n async function scanFolder(dir: string): Promise<void> {\n try {\n const entries = await fs.readdir(dir, { withFileTypes: true })\n for (const entry of entries) {\n if (entry.name.startsWith('.')) continue\n const fullPath = path.join(dir, entry.name)\n if (entry.isDirectory()) {\n await scanFolder(fullPath)\n } else if (isMediaFile(entry.name)) {\n fileCount++\n const stats = await fs.stat(fullPath)\n totalSize += stats.size\n }\n }\n } catch { /* ignore errors */ }\n }\n\n await scanFolder(folderPath)\n return { fileCount, totalSize }\n}\n","import { promises as fs } from 'fs'\nimport path from 'path'\nimport sharp from 'sharp'\nimport { encode } from 'blurhash'\nimport type { LeanImageEntry } from '../../types'\n\nexport const DEFAULT_SIZES: Record<string, { width: number; suffix: string }> = {\n small: { width: 300, suffix: '-sm' },\n medium: { width: 700, suffix: '-md' },\n large: { width: 1400, suffix: '-lg' },\n}\n\nexport async function processImage(\n buffer: Buffer,\n imageKey: string\n): Promise<LeanImageEntry> {\n const sharpInstance = sharp(buffer)\n const metadata = await sharpInstance.metadata()\n const originalWidth = metadata.width || 0\n const originalHeight = metadata.height || 0\n\n // Remove leading slash for path operations\n const keyWithoutSlash = imageKey.startsWith('/') ? imageKey.slice(1) : imageKey\n const baseName = path.basename(keyWithoutSlash, path.extname(keyWithoutSlash))\n const ext = path.extname(keyWithoutSlash).toLowerCase()\n const imageDir = path.dirname(keyWithoutSlash)\n\n const imagesPath = path.join(process.cwd(), 'public', 'images', imageDir === '.' ? '' : imageDir)\n await fs.mkdir(imagesPath, { recursive: true })\n\n const isPng = ext === '.png'\n const outputExt = isPng ? '.png' : '.jpg'\n \n // Generate full size\n const fullFileName = imageDir === '.' ? `${baseName}${outputExt}` : `${imageDir}/${baseName}${outputExt}`\n const fullPath = path.join(process.cwd(), 'public', 'images', fullFileName)\n \n if (isPng) {\n await sharp(buffer).png({ quality: 85 }).toFile(fullPath)\n } else {\n await sharp(buffer).jpeg({ quality: 85 }).toFile(fullPath)\n }\n\n // Generate thumbnail sizes\n for (const [, sizeConfig] of Object.entries(DEFAULT_SIZES)) {\n const { width: maxWidth, suffix } = sizeConfig\n if (originalWidth <= maxWidth) {\n continue // Skip if original is smaller than this size\n }\n\n const ratio = originalHeight / originalWidth\n const newHeight = Math.round(maxWidth * ratio)\n const sizeFileName = `${baseName}${suffix}${outputExt}`\n const sizeFilePath = imageDir === '.' ? sizeFileName : `${imageDir}/${sizeFileName}`\n const sizePath = path.join(process.cwd(), 'public', 'images', sizeFilePath)\n\n if (isPng) {\n await sharp(buffer).resize(maxWidth, newHeight).png({ quality: 80 }).toFile(sizePath)\n } else {\n await sharp(buffer).resize(maxWidth, newHeight).jpeg({ quality: 80 }).toFile(sizePath)\n }\n }\n\n // Generate blurhash\n const { data, info } = await sharp(buffer)\n .resize(32, 32, { fit: 'inside' })\n .ensureAlpha()\n .raw()\n .toBuffer({ resolveWithObject: true })\n\n const blurhash = encode(new Uint8ClampedArray(data), info.width, info.height, 4, 4)\n\n return {\n w: originalWidth,\n h: originalHeight,\n blur: blurhash,\n }\n}\n","import { promises as fs } from 'fs'\nimport path from 'path'\nimport { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'\nimport { getAllThumbnailPaths } from '../../types'\nimport { getContentType } from './files'\n\nfunction getR2Client() {\n const accountId = process.env.CLOUDFLARE_R2_ACCOUNT_ID\n const accessKeyId = process.env.CLOUDFLARE_R2_ACCESS_KEY_ID\n const secretAccessKey = process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY\n\n if (!accountId || !accessKeyId || !secretAccessKey) {\n throw new Error('R2 not configured')\n }\n\n return new S3Client({\n region: 'auto',\n endpoint: `https://${accountId}.r2.cloudflarestorage.com`,\n credentials: { accessKeyId, secretAccessKey },\n })\n}\n\nexport async function downloadFromCdn(originalPath: string): Promise<Buffer> {\n const bucketName = process.env.CLOUDFLARE_R2_BUCKET_NAME\n if (!bucketName) throw new Error('R2 bucket not configured')\n\n const r2 = getR2Client()\n\n const response = await r2.send(\n new GetObjectCommand({\n Bucket: bucketName,\n Key: originalPath.replace(/^\\//, ''),\n })\n )\n\n const stream = response.Body as NodeJS.ReadableStream\n const chunks: Buffer[] = []\n for await (const chunk of stream) {\n chunks.push(Buffer.from(chunk))\n }\n return Buffer.concat(chunks)\n}\n\nexport async function uploadToCdn(imageKey: string): Promise<void> {\n const bucketName = process.env.CLOUDFLARE_R2_BUCKET_NAME\n if (!bucketName) throw new Error('R2 bucket not configured')\n\n const r2 = getR2Client()\n\n // Upload all thumbnail sizes derived from imageKey\n for (const thumbPath of getAllThumbnailPaths(imageKey)) {\n const localPath = path.join(process.cwd(), 'public', thumbPath)\n try {\n const fileBuffer = await fs.readFile(localPath)\n await r2.send(\n new PutObjectCommand({\n Bucket: bucketName,\n Key: thumbPath.replace(/^\\//, ''),\n Body: fileBuffer,\n ContentType: getContentType(thumbPath),\n })\n )\n } catch {\n // File might not exist (e.g., if image is smaller than thumbnail size)\n }\n }\n}\n\nexport async function deleteLocalThumbnails(imageKey: string): Promise<void> {\n for (const thumbPath of getAllThumbnailPaths(imageKey)) {\n const localPath = path.join(process.cwd(), 'public', thumbPath)\n try {\n await fs.unlink(localPath)\n } catch {\n // File might not exist\n }\n }\n}\n","import { NextRequest, NextResponse } from 'next/server'\nimport { promises as fs } from 'fs'\nimport path from 'path'\nimport sharp from 'sharp'\nimport type { MetaEntry } from '../types'\nimport { getAllThumbnailPaths } from '../types'\nimport { loadMeta, saveMeta, isImageFile, isMediaFile } from './utils'\n\nexport async function handleUpload(request: NextRequest) {\n try {\n const formData = await request.formData()\n const file = formData.get('file') as File | null\n const targetPath = formData.get('path') as string || 'public'\n\n if (!file) {\n return NextResponse.json({ error: 'No file provided' }, { status: 400 })\n }\n\n const bytes = await file.arrayBuffer()\n const buffer = Buffer.from(bytes)\n\n const fileName = file.name\n const ext = path.extname(fileName).toLowerCase()\n\n const isImage = isImageFile(fileName)\n const isMedia = isMediaFile(fileName)\n\n const meta = await loadMeta()\n\n let relativeDir = ''\n if (targetPath === 'public') {\n relativeDir = ''\n } else if (targetPath.startsWith('public/')) {\n relativeDir = targetPath.replace('public/', '')\n }\n \n if (relativeDir === 'images' || relativeDir.startsWith('images/')) {\n return NextResponse.json(\n { error: 'Cannot upload to images/ folder. Upload to public/ instead - thumbnails are generated automatically.' },\n { status: 400 }\n )\n }\n\n // Build the meta key\n let imageKey = '/' + (relativeDir ? `${relativeDir}/${fileName}` : fileName)\n\n // Check for collision - rename if needed\n if (meta[imageKey]) {\n const baseName = path.basename(fileName, ext)\n let counter = 1\n let newFileName = `${baseName}-${counter}${ext}`\n let newKey = '/' + (relativeDir ? `${relativeDir}/${newFileName}` : newFileName)\n \n while (meta[newKey]) {\n counter++\n newFileName = `${baseName}-${counter}${ext}`\n newKey = '/' + (relativeDir ? `${relativeDir}/${newFileName}` : newFileName)\n }\n \n imageKey = newKey\n }\n\n // Extract actual filename from key\n const actualFileName = path.basename(imageKey)\n \n const uploadDir = path.join(process.cwd(), 'public', relativeDir)\n await fs.mkdir(uploadDir, { recursive: true })\n await fs.writeFile(path.join(uploadDir, actualFileName), buffer)\n\n if (!isMedia) {\n return NextResponse.json({ \n success: true, \n message: 'File uploaded (not a media file)',\n path: `public/${relativeDir ? relativeDir + '/' : ''}${actualFileName}`\n })\n }\n\n // Add to meta\n if (isImage && ext !== '.svg') {\n // Read dimensions for images\n try {\n const metadata = await sharp(buffer).metadata()\n meta[imageKey] = {\n w: metadata.width || 0,\n h: metadata.height || 0,\n }\n } catch {\n meta[imageKey] = { w: 0, h: 0 }\n }\n } else {\n // Non-image media or SVG\n meta[imageKey] = {}\n }\n\n await saveMeta(meta)\n\n return NextResponse.json({ \n success: true, \n imageKey,\n message: 'File uploaded. Run \"Process Images\" to generate thumbnails.'\n })\n } catch (error) {\n console.error('Failed to upload:', error)\n const message = error instanceof Error ? error.message : 'Unknown error'\n return NextResponse.json({ error: `Failed to upload file: ${message}` }, { status: 500 })\n }\n}\n\nexport async function handleDelete(request: NextRequest) {\n try {\n const { paths } = await request.json() as { paths: string[] }\n\n if (!paths || !Array.isArray(paths) || paths.length === 0) {\n return NextResponse.json({ error: 'No paths provided' }, { status: 400 })\n }\n\n const meta = await loadMeta()\n const deleted: string[] = []\n const errors: string[] = []\n\n for (const itemPath of paths) {\n try {\n if (!itemPath.startsWith('public/')) {\n errors.push(`Invalid path: ${itemPath}`)\n continue\n }\n\n const absolutePath = path.join(process.cwd(), itemPath)\n const imageKey = '/' + itemPath.replace(/^public\\//, '')\n \n // Check if this is in meta (could be synced with no local file)\n const entry = meta[imageKey]\n const isSynced = entry?.s === 1\n \n // Try to delete local file/folder\n try {\n const stats = await fs.stat(absolutePath)\n\n if (stats.isDirectory()) {\n await fs.rm(absolutePath, { recursive: true })\n \n // Remove all meta entries under this folder\n const prefix = imageKey + '/'\n for (const key of Object.keys(meta)) {\n if (key.startsWith(prefix) || key === imageKey) {\n // Also delete local thumbnails if not synced\n if (!meta[key].s) {\n for (const thumbPath of getAllThumbnailPaths(key)) {\n const absoluteThumbPath = path.join(process.cwd(), 'public', thumbPath)\n try { await fs.unlink(absoluteThumbPath) } catch { /* ignore */ }\n }\n }\n delete meta[key]\n }\n }\n } else {\n await fs.unlink(absolutePath)\n\n const isInImagesFolder = itemPath.startsWith('public/images/')\n \n if (!isInImagesFolder && entry) {\n // Delete local thumbnails if not synced\n if (!isSynced) {\n for (const thumbPath of getAllThumbnailPaths(imageKey)) {\n const absoluteThumbPath = path.join(process.cwd(), 'public', thumbPath)\n try { await fs.unlink(absoluteThumbPath) } catch { /* ignore */ }\n }\n }\n delete meta[imageKey]\n }\n }\n } catch {\n // File doesn't exist locally - might be synced\n if (entry) {\n // Just remove from meta (file is on CDN)\n delete meta[imageKey]\n } else {\n // Check if it's a folder prefix in meta\n const prefix = imageKey + '/'\n let foundAny = false\n for (const key of Object.keys(meta)) {\n if (key.startsWith(prefix)) {\n delete meta[key]\n foundAny = true\n }\n }\n if (!foundAny) {\n errors.push(`Not found: ${itemPath}`)\n continue\n }\n }\n }\n\n deleted.push(itemPath)\n } catch (error) {\n console.error(`Failed to delete ${itemPath}:`, error)\n errors.push(itemPath)\n }\n }\n\n await saveMeta(meta)\n\n return NextResponse.json({\n success: true,\n deleted,\n errors: errors.length > 0 ? errors : undefined,\n })\n } catch (error) {\n console.error('Failed to delete:', error)\n return NextResponse.json({ error: 'Failed to delete files' }, { status: 500 })\n }\n}\n\nexport async function handleCreateFolder(request: NextRequest) {\n try {\n const { parentPath, name } = await request.json()\n\n if (!name || typeof name !== 'string') {\n return NextResponse.json({ error: 'Folder name is required' }, { status: 400 })\n }\n\n const sanitizedName = name.replace(/[<>:\"/\\\\|?*]/g, '').trim()\n if (!sanitizedName) {\n return NextResponse.json({ error: 'Invalid folder name' }, { status: 400 })\n }\n\n const safePath = (parentPath || 'public').replace(/\\.\\./g, '')\n const folderPath = path.join(process.cwd(), safePath, sanitizedName)\n\n if (!folderPath.startsWith(path.join(process.cwd(), 'public'))) {\n return NextResponse.json({ error: 'Invalid path' }, { status: 400 })\n }\n\n try {\n await fs.access(folderPath)\n return NextResponse.json({ error: 'A folder with this name already exists' }, { status: 400 })\n } catch {\n // Good - folder doesn't exist\n }\n\n await fs.mkdir(folderPath, { recursive: true })\n\n return NextResponse.json({ success: true, path: path.join(safePath, sanitizedName) })\n } catch (error) {\n console.error('Failed to create folder:', error)\n return NextResponse.json({ error: 'Failed to create folder' }, { status: 500 })\n }\n}\n\nexport async function handleRename(request: NextRequest) {\n try {\n const { oldPath, newName } = await request.json()\n\n if (!oldPath || !newName) {\n return NextResponse.json({ error: 'Path and new name are required' }, { status: 400 })\n }\n\n const sanitizedName = newName.replace(/[<>:\"/\\\\|?*]/g, '').trim()\n if (!sanitizedName) {\n return NextResponse.json({ error: 'Invalid name' }, { status: 400 })\n }\n\n const safePath = oldPath.replace(/\\.\\./g, '')\n const absoluteOldPath = path.join(process.cwd(), safePath)\n const parentDir = path.dirname(absoluteOldPath)\n const absoluteNewPath = path.join(parentDir, sanitizedName)\n\n if (!absoluteOldPath.startsWith(path.join(process.cwd(), 'public'))) {\n return NextResponse.json({ error: 'Invalid path' }, { status: 400 })\n }\n\n try {\n await fs.access(absoluteOldPath)\n } catch {\n return NextResponse.json({ error: 'File or folder not found' }, { status: 404 })\n }\n\n try {\n await fs.access(absoluteNewPath)\n return NextResponse.json({ error: 'An item with this name already exists' }, { status: 400 })\n } catch {\n // Good - new path doesn't exist\n }\n\n const stats = await fs.stat(absoluteOldPath)\n const isFile = stats.isFile()\n const isImage = isFile && isImageFile(path.basename(oldPath))\n\n await fs.rename(absoluteOldPath, absoluteNewPath)\n\n if (isImage) {\n const meta = await loadMeta()\n const oldRelativePath = safePath.replace(/^public\\//, '')\n const newRelativePath = path.join(path.dirname(oldRelativePath), sanitizedName)\n const oldKey = '/' + oldRelativePath\n const newKey = '/' + newRelativePath\n\n if (meta[oldKey]) {\n const entry = meta[oldKey]\n\n const oldThumbPaths = getAllThumbnailPaths(oldKey)\n const newThumbPaths = getAllThumbnailPaths(newKey)\n\n for (let i = 0; i < oldThumbPaths.length; i++) {\n const oldThumbPath = path.join(process.cwd(), 'public', oldThumbPaths[i])\n const newThumbPath = path.join(process.cwd(), 'public', newThumbPaths[i])\n \n await fs.mkdir(path.dirname(newThumbPath), { recursive: true })\n \n try {\n await fs.rename(oldThumbPath, newThumbPath)\n } catch {\n // Thumbnail might not exist\n }\n }\n\n delete meta[oldKey]\n meta[newKey] = entry\n }\n\n await saveMeta(meta)\n }\n\n const newPath = path.join(path.dirname(safePath), sanitizedName)\n return NextResponse.json({ success: true, newPath })\n } catch (error) {\n console.error('Failed to rename:', error)\n return NextResponse.json({ error: 'Failed to rename' }, { status: 500 })\n }\n}\n\nexport async function handleMove(request: NextRequest) {\n try {\n const { paths, destination } = await request.json()\n\n if (!paths || !Array.isArray(paths) || paths.length === 0) {\n return NextResponse.json({ error: 'Paths are required' }, { status: 400 })\n }\n\n if (!destination || typeof destination !== 'string') {\n return NextResponse.json({ error: 'Destination is required' }, { status: 400 })\n }\n\n const safeDestination = destination.replace(/\\.\\./g, '')\n const absoluteDestination = path.join(process.cwd(), safeDestination)\n\n if (!absoluteDestination.startsWith(path.join(process.cwd(), 'public'))) {\n return NextResponse.json({ error: 'Invalid destination' }, { status: 400 })\n }\n\n try {\n const destStats = await fs.stat(absoluteDestination)\n if (!destStats.isDirectory()) {\n return NextResponse.json({ error: 'Destination is not a folder' }, { status: 400 })\n }\n } catch {\n return NextResponse.json({ error: 'Destination folder not found' }, { status: 404 })\n }\n\n const moved: string[] = []\n const errors: string[] = []\n const meta = await loadMeta()\n let metaChanged = false\n\n for (const itemPath of paths) {\n const safePath = itemPath.replace(/\\.\\./g, '')\n const absolutePath = path.join(process.cwd(), safePath)\n const itemName = path.basename(safePath)\n const newAbsolutePath = path.join(absoluteDestination, itemName)\n\n if (absoluteDestination.startsWith(absolutePath + path.sep)) {\n errors.push(`Cannot move ${itemName} into itself`)\n continue\n }\n\n try {\n await fs.access(absolutePath)\n } catch {\n errors.push(`${itemName} not found`)\n continue\n }\n\n try {\n await fs.access(newAbsolutePath)\n errors.push(`${itemName} already exists in destination`)\n continue\n } catch {\n // Good - doesn't exist\n }\n\n try {\n await fs.rename(absolutePath, newAbsolutePath)\n\n const stats = await fs.stat(newAbsolutePath)\n if (stats.isFile() && isImageFile(itemName)) {\n const oldRelativePath = safePath.replace(/^public\\//, '')\n const newRelativePath = path.join(safeDestination.replace(/^public\\//, ''), itemName)\n const oldKey = '/' + oldRelativePath\n const newKey = '/' + newRelativePath\n\n if (meta[oldKey]) {\n const entry = meta[oldKey]\n\n const oldThumbPaths = getAllThumbnailPaths(oldKey)\n const newThumbPaths = getAllThumbnailPaths(newKey)\n\n for (let i = 0; i < oldThumbPaths.length; i++) {\n const oldThumbPath = path.join(process.cwd(), 'public', oldThumbPaths[i])\n const newThumbPath = path.join(process.cwd(), 'public', newThumbPaths[i])\n \n await fs.mkdir(path.dirname(newThumbPath), { recursive: true })\n\n try {\n await fs.rename(oldThumbPath, newThumbPath)\n } catch {\n // Thumbnail might not exist\n }\n }\n\n delete meta[oldKey]\n meta[newKey] = entry\n metaChanged = true\n }\n }\n\n moved.push(itemPath)\n } catch {\n errors.push(`Failed to move ${itemName}`)\n }\n }\n\n if (metaChanged) {\n await saveMeta(meta)\n }\n\n return NextResponse.json({\n success: errors.length === 0,\n moved,\n errors: errors.length > 0 ? errors : undefined\n })\n } catch (error) {\n console.error('Failed to move:', error)\n return NextResponse.json({ error: 'Failed to move items' }, { status: 500 })\n }\n}\n","import { NextRequest, NextResponse } from 'next/server'\nimport { promises as fs } from 'fs'\nimport path from 'path'\nimport { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'\nimport { getAllThumbnailPaths } from '../types'\nimport {\n loadMeta,\n saveMeta,\n isImageFile,\n getContentType,\n processImage,\n downloadFromCdn,\n uploadToCdn,\n deleteLocalThumbnails,\n} from './utils'\n\nexport async function handleSync(request: NextRequest) {\n const accountId = process.env.CLOUDFLARE_R2_ACCOUNT_ID\n const accessKeyId = process.env.CLOUDFLARE_R2_ACCESS_KEY_ID\n const secretAccessKey = process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY\n const bucketName = process.env.CLOUDFLARE_R2_BUCKET_NAME\n const publicUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL\n\n if (!accountId || !accessKeyId || !secretAccessKey || !bucketName || !publicUrl) {\n return NextResponse.json(\n { error: 'R2 not configured. Set CLOUDFLARE_R2_* environment variables.' },\n { status: 400 }\n )\n }\n\n try {\n const { imageKeys } = await request.json() as { imageKeys: string[] }\n\n if (!imageKeys || !Array.isArray(imageKeys) || imageKeys.length === 0) {\n return NextResponse.json({ error: 'No image keys provided' }, { status: 400 })\n }\n\n const meta = await loadMeta()\n\n const r2 = new S3Client({\n region: 'auto',\n endpoint: `https://${accountId}.r2.cloudflarestorage.com`,\n credentials: { accessKeyId, secretAccessKey },\n })\n\n const synced: string[] = []\n const errors: string[] = []\n\n for (const imageKey of imageKeys) {\n const entry = meta[imageKey]\n if (!entry) {\n errors.push(`Image not found in meta: ${imageKey}. Run Scan first.`)\n continue\n }\n\n if (entry.s) {\n synced.push(imageKey)\n continue\n }\n\n try {\n // Upload original file first\n const originalLocalPath = path.join(process.cwd(), 'public', imageKey)\n try {\n const originalBuffer = await fs.readFile(originalLocalPath)\n await r2.send(\n new PutObjectCommand({\n Bucket: bucketName,\n Key: imageKey.replace(/^\\//, ''),\n Body: originalBuffer,\n ContentType: getContentType(imageKey),\n })\n )\n } catch (err) {\n errors.push(`Original file not found: ${imageKey}`)\n continue\n }\n\n // Upload thumbnails\n for (const thumbPath of getAllThumbnailPaths(imageKey)) {\n const localPath = path.join(process.cwd(), 'public', thumbPath)\n try {\n const fileBuffer = await fs.readFile(localPath)\n await r2.send(\n new PutObjectCommand({\n Bucket: bucketName,\n Key: thumbPath.replace(/^\\//, ''),\n Body: fileBuffer,\n ContentType: getContentType(thumbPath),\n })\n )\n } catch {\n // Thumbnail might not exist (not processed yet)\n }\n }\n\n entry.s = 1\n\n // Delete local thumbnails\n for (const thumbPath of getAllThumbnailPaths(imageKey)) {\n const localPath = path.join(process.cwd(), 'public', thumbPath)\n try { await fs.unlink(localPath) } catch { /* ignore */ }\n }\n\n // Delete local original\n try { await fs.unlink(originalLocalPath) } catch { /* ignore */ }\n\n synced.push(imageKey)\n } catch (error) {\n console.error(`Failed to sync ${imageKey}:`, error)\n errors.push(`Failed to sync: ${imageKey}`)\n }\n }\n\n await saveMeta(meta)\n\n return NextResponse.json({\n success: true,\n synced,\n errors: errors.length > 0 ? errors : undefined,\n })\n } catch (error) {\n console.error('Failed to sync:', error)\n return NextResponse.json({ error: 'Failed to sync to CDN' }, { status: 500 })\n }\n}\n\nexport async function handleReprocess(request: NextRequest) {\n try {\n const { imageKeys } = await request.json() as { imageKeys: string[] }\n\n if (!imageKeys || !Array.isArray(imageKeys) || imageKeys.length === 0) {\n return NextResponse.json({ error: 'No image keys provided' }, { status: 400 })\n }\n\n const meta = await loadMeta()\n const processed: string[] = []\n const errors: string[] = []\n\n for (const imageKey of imageKeys) {\n try {\n let buffer: Buffer\n const entry = meta[imageKey]\n const isSynced = entry?.s === 1\n \n const originalPath = path.join(process.cwd(), 'public', imageKey)\n \n try {\n buffer = await fs.readFile(originalPath)\n } catch {\n if (isSynced) {\n // Download original from CDN to local path\n buffer = await downloadFromCdn(imageKey)\n // Save to local path for processing\n const dir = path.dirname(originalPath)\n await fs.mkdir(dir, { recursive: true })\n await fs.writeFile(originalPath, buffer)\n } else {\n throw new Error(`File not found: ${imageKey}`)\n }\n }\n\n const updatedEntry = await processImage(buffer, imageKey)\n \n if (isSynced) {\n // Re-upload to CDN and clean up local files\n updatedEntry.s = 1\n await uploadToCdn(imageKey)\n await deleteLocalThumbnails(imageKey)\n // Delete local original\n try { await fs.unlink(originalPath) } catch { /* ignore */ }\n }\n \n meta[imageKey] = updatedEntry\n processed.push(imageKey)\n } catch (error) {\n console.error(`Failed to reprocess ${imageKey}:`, error)\n errors.push(imageKey)\n }\n }\n\n await saveMeta(meta)\n\n return NextResponse.json({\n success: true,\n processed,\n errors: errors.length > 0 ? errors : undefined,\n })\n } catch (error) {\n console.error('Failed to reprocess:', error)\n return NextResponse.json({ error: 'Failed to reprocess images' }, { status: 500 })\n }\n}\n\nexport async function handleProcessAllStream() {\n const encoder = new TextEncoder()\n \n const stream = new ReadableStream({\n async start(controller) {\n const sendEvent = (data: object) => {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\\n\\n`))\n }\n\n try {\n const meta = await loadMeta()\n const processed: string[] = []\n const errors: string[] = []\n const orphansRemoved: string[] = []\n\n // Get all images from meta that need processing (not synced, no blur yet)\n const imagesToProcess: Array<{ key: string; entry: typeof meta[string] }> = []\n \n for (const [key, entry] of Object.entries(meta)) {\n // Skip synced images - they're already processed and on CDN\n if (entry.s) continue\n \n // Skip non-images (no w/h means it was added as non-image or SVG)\n const fileName = path.basename(key)\n if (!isImageFile(fileName)) continue\n \n // Check if needs processing (no blur = not processed yet)\n if (!entry.blur) {\n imagesToProcess.push({ key, entry })\n }\n }\n\n const total = imagesToProcess.length\n sendEvent({ type: 'start', total })\n\n for (let i = 0; i < imagesToProcess.length; i++) {\n const { key } = imagesToProcess[i]\n const fullPath = path.join(process.cwd(), 'public', key)\n \n sendEvent({ \n type: 'progress', \n current: i + 1, \n total, \n percent: Math.round(((i + 1) / total) * 100),\n currentFile: key.slice(1) // Remove leading /\n })\n\n try {\n const buffer = await fs.readFile(fullPath)\n const ext = path.extname(key).toLowerCase()\n const isSvg = ext === '.svg'\n\n if (isSvg) {\n const imageDir = path.dirname(key.slice(1))\n const imagesPath = path.join(process.cwd(), 'public', 'images', imageDir === '.' ? '' : imageDir)\n await fs.mkdir(imagesPath, { recursive: true })\n \n const fileName = path.basename(key)\n const destPath = path.join(imagesPath, fileName)\n await fs.writeFile(destPath, buffer)\n\n meta[key] = {\n w: 0,\n h: 0,\n blur: '',\n }\n } else {\n const processedEntry = await processImage(buffer, key)\n meta[key] = processedEntry\n }\n\n processed.push(key.slice(1))\n } catch (error) {\n console.error(`Failed to process ${key}:`, error)\n errors.push(key.slice(1))\n }\n }\n\n sendEvent({ type: 'cleanup', message: 'Removing orphaned thumbnails...' })\n \n // Build set of expected thumbnail paths\n const trackedPaths = new Set<string>()\n for (const imageKey of Object.keys(meta)) {\n // Only track local thumbnails (not synced)\n if (!meta[imageKey].s) {\n for (const thumbPath of getAllThumbnailPaths(imageKey)) {\n trackedPaths.add(thumbPath)\n }\n }\n }\n\n async function findOrphans(dir: string, relativePath: string = ''): Promise<void> {\n try {\n const entries = await fs.readdir(dir, { withFileTypes: true })\n \n for (const entry of entries) {\n if (entry.name.startsWith('.')) continue\n\n const fullPath = path.join(dir, entry.name)\n const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name\n\n if (entry.isDirectory()) {\n await findOrphans(fullPath, relPath)\n } else if (isImageFile(entry.name)) {\n const publicPath = `/images/${relPath}`\n if (!trackedPaths.has(publicPath)) {\n try {\n await fs.unlink(fullPath)\n orphansRemoved.push(publicPath)\n } catch (err) {\n console.error(`Failed to remove orphan ${publicPath}:`, err)\n }\n }\n }\n }\n } catch {\n // Directory might not exist\n }\n }\n\n const imagesDir = path.join(process.cwd(), 'public', 'images')\n try {\n await findOrphans(imagesDir)\n } catch {\n // images dir might not exist\n }\n\n async function removeEmptyDirs(dir: string): Promise<boolean> {\n try {\n const entries = await fs.readdir(dir, { withFileTypes: true })\n let isEmpty = true\n\n for (const entry of entries) {\n if (entry.isDirectory()) {\n const subDirEmpty = await removeEmptyDirs(path.join(dir, entry.name))\n if (!subDirEmpty) isEmpty = false\n } else {\n isEmpty = false\n }\n }\n\n if (isEmpty && dir !== imagesDir) {\n await fs.rmdir(dir)\n }\n\n return isEmpty\n } catch {\n return true\n }\n }\n\n try {\n await removeEmptyDirs(imagesDir)\n } catch {\n // images dir might not exist\n }\n \n await saveMeta(meta)\n\n sendEvent({ \n type: 'complete', \n processed: processed.length, \n orphansRemoved: orphansRemoved.length,\n errors: errors.length,\n })\n } catch (error) {\n console.error('Failed to process all:', error)\n sendEvent({ type: 'error', message: 'Failed to process images' })\n } finally {\n controller.close()\n }\n }\n })\n\n return new Response(stream, {\n headers: {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache',\n 'Connection': 'keep-alive',\n },\n })\n}\n","import { promises as fs } from 'fs'\nimport path from 'path'\nimport sharp from 'sharp'\nimport { loadMeta, saveMeta, isMediaFile, isImageFile } from './utils'\n\n/**\n * Streaming scan handler - scans filesystem for new files not in meta\n * For images, reads dimensions (w/h)\n * Handles collisions by renaming files with -1, -2, etc.\n */\nexport async function handleScanStream() {\n const encoder = new TextEncoder()\n \n const stream = new ReadableStream({\n async start(controller) {\n const sendEvent = (data: object) => {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\\n\\n`))\n }\n\n try {\n const meta = await loadMeta()\n const existingKeys = new Set(Object.keys(meta))\n const added: string[] = []\n const renamed: Array<{ from: string; to: string }> = []\n const errors: string[] = []\n\n // Collect all files first\n const allFiles: Array<{ relativePath: string; fullPath: string }> = []\n\n async function scanDir(dir: string, relativePath: string = ''): Promise<void> {\n try {\n const entries = await fs.readdir(dir, { withFileTypes: true })\n \n for (const entry of entries) {\n if (entry.name.startsWith('.')) continue\n \n const fullPath = path.join(dir, entry.name)\n const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name\n\n // Skip the images folder (generated thumbnails)\n if (relPath === 'images' || relPath.startsWith('images/')) continue\n\n if (entry.isDirectory()) {\n await scanDir(fullPath, relPath)\n } else if (isMediaFile(entry.name)) {\n allFiles.push({ relativePath: relPath, fullPath })\n }\n }\n } catch {\n // Directory might not exist\n }\n }\n\n const publicDir = path.join(process.cwd(), 'public')\n await scanDir(publicDir)\n\n const total = allFiles.length\n sendEvent({ type: 'start', total })\n\n for (let i = 0; i < allFiles.length; i++) {\n let { relativePath, fullPath } = allFiles[i]\n let imageKey = '/' + relativePath\n \n sendEvent({ \n type: 'progress', \n current: i + 1, \n total, \n percent: Math.round(((i + 1) / total) * 100),\n currentFile: relativePath \n })\n\n // Check if already in meta\n if (existingKeys.has(imageKey)) {\n // File already tracked - skip\n continue\n }\n\n // Check for collision (path exists in meta but file is new)\n if (meta[imageKey]) {\n // Need to rename this file to avoid collision\n const ext = path.extname(relativePath)\n const baseName = relativePath.slice(0, -ext.length)\n let counter = 1\n let newKey = `/${baseName}-${counter}${ext}`\n \n while (meta[newKey]) {\n counter++\n newKey = `/${baseName}-${counter}${ext}`\n }\n \n // Rename the physical file\n const newRelativePath = `${baseName}-${counter}${ext}`\n const newFullPath = path.join(process.cwd(), 'public', newRelativePath)\n \n try {\n await fs.rename(fullPath, newFullPath)\n renamed.push({ from: relativePath, to: newRelativePath })\n relativePath = newRelativePath\n fullPath = newFullPath\n imageKey = newKey\n } catch (err) {\n console.error(`Failed to rename ${relativePath}:`, err)\n errors.push(`Failed to rename ${relativePath}`)\n continue\n }\n }\n\n try {\n const isImage = isImageFile(relativePath)\n \n if (isImage) {\n // Read dimensions for images\n const ext = path.extname(relativePath).toLowerCase()\n \n if (ext === '.svg') {\n // SVGs don't have pixel dimensions in the same way\n meta[imageKey] = { w: 0, h: 0 }\n } else {\n try {\n const metadata = await sharp(fullPath).metadata()\n meta[imageKey] = {\n w: metadata.width || 0,\n h: metadata.height || 0,\n }\n } catch {\n // Couldn't read dimensions\n meta[imageKey] = { w: 0, h: 0 }\n }\n }\n } else {\n // Non-image files - just add empty entry\n meta[imageKey] = {}\n }\n \n existingKeys.add(imageKey)\n added.push(imageKey)\n } catch (error) {\n console.error(`Failed to process ${relativePath}:`, error)\n errors.push(relativePath)\n }\n }\n\n await saveMeta(meta)\n\n sendEvent({ \n type: 'complete', \n added: added.length, \n renamed: renamed.length,\n errors: errors.length,\n renamedFiles: renamed,\n })\n } catch (error) {\n console.error('Scan failed:', error)\n sendEvent({ type: 'error', message: 'Scan failed' })\n } finally {\n controller.close()\n }\n }\n })\n\n return new Response(stream, {\n headers: {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache',\n 'Connection': 'keep-alive',\n },\n })\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["/Users/chrisb/Sites/studio/dist/handlers/index.js","../../src/handlers/index.ts","../../src/handlers/list.ts","../../src/handlers/utils/meta.ts","../../src/handlers/utils/files.ts","../../src/handlers/utils/thumbnails.ts","../../src/handlers/utils/cdn.ts","../../src/handlers/files.ts","../../src/handlers/images.ts","../../src/handlers/scan.ts"],"names":["path","fs","NextResponse","sharp"],"mappings":"AAAA;AACE;AACA;AACF,uDAA6B;AAC7B;AACA;ACLA,qCAA0C;ADO1C;AACA;AERA;AACA,wBAA+B;AAC/B,wEAAiB;AFUjB;AACA;AGbA;AACA;AAGA,MAAA,SAAsB,QAAA,CAAA,EAA8B;AAClD,EAAA,MAAM,SAAA,EAAW,cAAA,CAAK,IAAA,CAAK,OAAA,CAAQ,GAAA,CAAI,CAAA,EAAG,OAAA,EAAS,YAAY,CAAA;AAE/D,EAAA,IAAI;AACF,IAAA,MAAM,QAAA,EAAU,MAAM,YAAA,CAAG,QAAA,CAAS,QAAA,EAAU,OAAO,CAAA;AACnD,IAAA,OAAO,IAAA,CAAK,KAAA,CAAM,OAAO,CAAA;AAAA,EAC3B,EAAA,UAAQ;AACN,IAAA,OAAO,CAAC,CAAA;AAAA,EACV;AACF;AAEA,MAAA,SAAsB,QAAA,CAAS,IAAA,EAA+B;AAC5D,EAAA,MAAM,QAAA,EAAU,cAAA,CAAK,IAAA,CAAK,OAAA,CAAQ,GAAA,CAAI,CAAA,EAAG,OAAO,CAAA;AAChD,EAAA,MAAM,YAAA,CAAG,KAAA,CAAM,OAAA,EAAS,EAAE,SAAA,EAAW,KAAK,CAAC,CAAA;AAC3C,EAAA,MAAM,SAAA,EAAW,cAAA,CAAK,IAAA,CAAK,OAAA,EAAS,YAAY,CAAA;AAChD,EAAA,MAAM,YAAA,CAAG,SAAA,CAAU,QAAA,EAAU,IAAA,CAAK,SAAA,CAAU,IAAA,EAAM,IAAA,EAAM,CAAC,CAAC,CAAA;AAC5D;AHWA;AACA;AI/BA;AAEO,SAAS,WAAA,CAAY,QAAA,EAA2B;AACrD,EAAA,MAAM,IAAA,EAAMA,cAAAA,CAAK,OAAA,CAAQ,QAAQ,CAAA,CAAE,WAAA,CAAY,CAAA;AAC/C,EAAA,OAAO,CAAC,MAAA,EAAQ,OAAA,EAAS,MAAA,EAAQ,MAAA,EAAQ,OAAA,EAAS,MAAA,EAAQ,MAAA,EAAQ,MAAA,EAAQ,OAAA,EAAS,MAAM,CAAA,CAAE,QAAA,CAAS,GAAG,CAAA;AACzG;AAEO,SAAS,WAAA,CAAY,QAAA,EAA2B;AACrD,EAAA,MAAM,IAAA,EAAMA,cAAAA,CAAK,OAAA,CAAQ,QAAQ,CAAA,CAAE,WAAA,CAAY,CAAA;AAE/C,EAAA,GAAA,CAAI,CAAC,MAAA,EAAQ,OAAA,EAAS,MAAA,EAAQ,MAAA,EAAQ,OAAA,EAAS,MAAA,EAAQ,MAAA,EAAQ,MAAA,EAAQ,OAAA,EAAS,MAAM,CAAA,CAAE,QAAA,CAAS,GAAG,CAAA,EAAG,OAAO,IAAA;AAE9G,EAAA,GAAA,CAAI,CAAC,MAAA,EAAQ,OAAA,EAAS,MAAA,EAAQ,MAAA,EAAQ,MAAA,EAAQ,MAAM,CAAA,CAAE,QAAA,CAAS,GAAG,CAAA,EAAG,OAAO,IAAA;AAE5E,EAAA,GAAA,CAAI,CAAC,MAAA,EAAQ,MAAA,EAAQ,MAAA,EAAQ,MAAA,EAAQ,OAAA,EAAS,MAAM,CAAA,CAAE,QAAA,CAAS,GAAG,CAAA,EAAG,OAAO,IAAA;AAE5E,EAAA,GAAA,CAAI,CAAC,MAAM,CAAA,CAAE,QAAA,CAAS,GAAG,CAAA,EAAG,OAAO,IAAA;AACnC,EAAA,OAAO,KAAA;AACT;AAEO,SAAS,cAAA,CAAe,QAAA,EAA0B;AACvD,EAAA,MAAM,IAAA,EAAMA,cAAAA,CAAK,OAAA,CAAQ,QAAQ,CAAA,CAAE,WAAA,CAAY,CAAA;AAC/C,EAAA,OAAA,CAAQ,GAAA,EAAK;AAAA,IACX,KAAK,MAAA;AAAA,IACL,KAAK,OAAA;AACH,MAAA,OAAO,YAAA;AAAA,IACT,KAAK,MAAA;AACH,MAAA,OAAO,WAAA;AAAA,IACT,KAAK,MAAA;AACH,MAAA,OAAO,WAAA;AAAA,IACT,KAAK,OAAA;AACH,MAAA,OAAO,YAAA;AAAA,IACT,KAAK,MAAA;AACH,MAAA,OAAO,eAAA;AAAA,IACT,OAAA;AACE,MAAA,OAAO,0BAAA;AAAA,EACX;AACF;AJ0BA;AACA;AKjEA;AACA;AACA,4EAAkB;AAClB,oCAAuB;AAGhB,IAAM,cAAA,EAAmE;AAAA,EAC9E,KAAA,EAAO,EAAE,KAAA,EAAO,GAAA,EAAK,MAAA,EAAQ,MAAM,CAAA;AAAA,EACnC,MAAA,EAAQ,EAAE,KAAA,EAAO,GAAA,EAAK,MAAA,EAAQ,MAAM,CAAA;AAAA,EACpC,KAAA,EAAO,EAAE,KAAA,EAAO,IAAA,EAAM,MAAA,EAAQ,MAAM;AACtC,CAAA;AAEA,MAAA,SAAsB,YAAA,CACpB,MAAA,EACA,QAAA,EACoB;AACpB,EAAA,MAAM,cAAA,EAAgB,6BAAA,MAAY,CAAA;AAClC,EAAA,MAAM,SAAA,EAAW,MAAM,aAAA,CAAc,QAAA,CAAS,CAAA;AAC9C,EAAA,MAAM,cAAA,EAAgB,QAAA,CAAS,MAAA,GAAS,CAAA;AACxC,EAAA,MAAM,eAAA,EAAiB,QAAA,CAAS,OAAA,GAAU,CAAA;AAG1C,EAAA,MAAM,gBAAA,EAAkB,QAAA,CAAS,UAAA,CAAW,GAAG,EAAA,EAAI,QAAA,CAAS,KAAA,CAAM,CAAC,EAAA,EAAI,QAAA;AACvE,EAAA,MAAM,SAAA,EAAWA,cAAAA,CAAK,QAAA,CAAS,eAAA,EAAiBA,cAAAA,CAAK,OAAA,CAAQ,eAAe,CAAC,CAAA;AAC7E,EAAA,MAAM,IAAA,EAAMA,cAAAA,CAAK,OAAA,CAAQ,eAAe,CAAA,CAAE,WAAA,CAAY,CAAA;AACtD,EAAA,MAAM,SAAA,EAAWA,cAAAA,CAAK,OAAA,CAAQ,eAAe,CAAA;AAE7C,EAAA,MAAM,WAAA,EAAaA,cAAAA,CAAK,IAAA,CAAK,OAAA,CAAQ,GAAA,CAAI,CAAA,EAAG,QAAA,EAAU,QAAA,EAAU,SAAA,IAAa,IAAA,EAAM,GAAA,EAAK,QAAQ,CAAA;AAChG,EAAA,MAAMC,YAAAA,CAAG,KAAA,CAAM,UAAA,EAAY,EAAE,SAAA,EAAW,KAAK,CAAC,CAAA;AAE9C,EAAA,MAAM,MAAA,EAAQ,IAAA,IAAQ,MAAA;AACtB,EAAA,MAAM,UAAA,EAAY,MAAA,EAAQ,OAAA,EAAS,MAAA;AAGnC,EAAA,MAAM,aAAA,EAAe,SAAA,IAAa,IAAA,EAAM,CAAA,EAAA;AACD,EAAA;AAE5B,EAAA;AAC8B,IAAA;AAClC,EAAA;AACkC,IAAA;AACzC,EAAA;AAGoC,EAAA;AACE,IAAA;AACL,IAAA;AAC7B,MAAA;AACF,IAAA;AAE+B,IAAA;AACF,IAAA;AACM,IAAA;AACD,IAAA;AACC,IAAA;AAExB,IAAA;AAC4B,MAAA;AAChC,IAAA;AACgC,MAAA;AACvC,IAAA;AACF,EAAA;AAGyC,EAAA;AAMb,EAAA;AAErB,EAAA;AACF,IAAA;AACA,IAAA;AACA,IAAA;AACL,EAAA;AACF;AL0C2C;AACA;AMxHZ;AACd;AACoB;AAId;AACS,EAAA;AACE,EAAA;AACI,EAAA;AAED,EAAA;AACE,IAAA;AACrC,EAAA;AAEoB,EAAA;AACV,IAAA;AACsB,IAAA;AACF,IAAA;AAC7B,EAAA;AACH;AAEsC;AACL,EAAA;AACE,EAAA;AAEV,EAAA;AAEG,EAAA;AACH,IAAA;AACX,MAAA;AAC2B,MAAA;AACpC,IAAA;AACH,EAAA;AAEwB,EAAA;AACE,EAAA;AACQ,EAAA;AACF,IAAA;AAChC,EAAA;AAC2B,EAAA;AAC7B;AAEmE;AAClC,EAAA;AACE,EAAA;AAEV,EAAA;AAGC,EAAA;AACc,IAAA;AAChC,IAAA;AAC0B,MAAA;AACnB,MAAA;AACc,QAAA;AACX,UAAA;AACwB,UAAA;AAC1B,UAAA;AACsB,UAAA;AAC7B,QAAA;AACH,MAAA;AACM,IAAA;AAER,IAAA;AACF,EAAA;AACF;AAE4C;AAClB,EAAA;AACc,IAAA;AAChC,IAAA;AACuB,MAAA;AACnB,IAAA;AAER,IAAA;AACF,EAAA;AACF;AN0G2C;AACA;AE7KY;AAChB,EAAA;AACE,EAAA;AAEnC,EAAA;AAC0B,IAAA;AACK,IAAA;AAGN,IAAA;AACU,MAAA;AACrC,IAAA;AAKmC,IAAA;AACG,IAAA;AAEX,IAAA;AACP,IAAA;AAEQ,IAAA;AACJ,MAAA;AAGa,MAAA;AACJ,MAAA;AAGE,MAAA;AAGjB,MAAA;AAGqB,MAAA;AAEd,MAAA;AAEc,QAAA;AAED,QAAA;AACN,UAAA;AAGL,UAAA;AACL,UAAA;AACU,UAAA;AACQ,YAAA;AAClC,UAAA;AAEW,UAAA;AACH,YAAA;AACyB,YAAA;AACzB,YAAA;AACN,YAAA;AACD,UAAA;AACH,QAAA;AACK,MAAA;AAEY,QAAA;AACW,QAAA;AACC,QAAA;AAEzB,QAAA;AACe,QAAA;AACf,QAAA;AAE6B,QAAA;AAEb,UAAA;AAEJ,UAAA;AAEe,YAAA;AACf,YAAA;AACc,cAAA;AACT,cAAA;AACjB,YAAA;AACK,UAAA;AAEuB,YAAA;AACxB,YAAA;AACc,cAAA;AACJ,cAAA;AACG,cAAA;AACT,YAAA;AAEM,cAAA;AACG,cAAA;AACjB,YAAA;AACF,UAAA;AACkB,QAAA;AAEN,UAAA;AACG,UAAA;AACjB,QAAA;AAGe,QAAA;AACT,UAAA;AACyB,YAAA;AACC,YAAA;AACX,YAAA;AACX,UAAA;AAER,UAAA;AACF,QAAA;AAEW,QAAA;AACH,UAAA;AACyB,UAAA;AACzB,UAAA;AACA,UAAA;AACN,UAAA;AACA,UAAA;AACW,UAAA;AACsB,UAAA;AAClC,QAAA;AACH,MAAA;AACF,IAAA;AAEkC,IAAA;AACpB,EAAA;AACA,IAAA;AACoB,IAAA;AACpC,EAAA;AACF;AAEyD;AAClB,EAAA;AACA,EAAA;AAEf,EAAA;AACkB,IAAA;AACxC,EAAA;AAEI,EAAA;AAC0B,IAAA;AACD,IAAA;AAEO,IAAA;AAEK,MAAA;AAEH,MAAA;AACF,MAAA;AACI,MAAA;AACP,MAAA;AAEzB,MAAA;AACe,MAAA;AAEkB,MAAA;AACA,QAAA;AAErB,QAAA;AACe,UAAA;AACf,UAAA;AACc,YAAA;AACT,YAAA;AACjB,UAAA;AACK,QAAA;AACuB,UAAA;AACxB,UAAA;AAC4B,YAAA;AAClB,YAAA;AACG,YAAA;AACT,UAAA;AACM,YAAA;AACG,YAAA;AACjB,UAAA;AACF,QAAA;AACkB,MAAA;AACN,QAAA;AACG,QAAA;AACjB,MAAA;AAEW,MAAA;AACH,QAAA;AACsB,QAAA;AACtB,QAAA;AACN,QAAA;AACA,QAAA;AACW,QAAA;AACwB,QAAA;AACpC,MAAA;AACH,IAAA;AAEkC,IAAA;AACpB,EAAA;AACqB,IAAA;AACD,IAAA;AACpC,EAAA;AACF;AAE0C;AACpC,EAAA;AAC0B,IAAA;AACN,IAAA;AAGe,IAAA;AACR,MAAA;AAEb,MAAA;AACqB,MAAA;AACC,QAAA;AACb,QAAA;AACvB,MAAA;AACF,IAAA;AAEkE,IAAA;AAC7B,IAAA;AAEJ,IAAA;AACR,IAAA;AACa,MAAA;AACD,MAAA;AACtB,MAAA;AACe,QAAA;AAC1B,QAAA;AACA,QAAA;AACD,MAAA;AACH,IAAA;AAEoC,IAAA;AACtB,EAAA;AACA,IAAA;AACoB,IAAA;AACpC,EAAA;AACF;AAE0C;AACpC,EAAA;AAC0B,IAAA;AACC,IAAA;AAEQ,IAAA;AACD,MAAA;AACP,MAAA;AACE,QAAA;AAC7B,MAAA;AACF,IAAA;AAEyB,IAAA;AACN,MAAA;AACT,MAAA;AACT,IAAA;AACa,EAAA;AACA,IAAA;AACoB,IAAA;AACpC,EAAA;AACF;AAE+D;AACzD,EAAA;AACmC,IAAA;AACC,IAAA;AAEnB,IAAA;AACiB,MAAA;AACpC,IAAA;AAEsC,IAAA;AACV,IAAA;AACC,IAAA;AAGK,IAAA;AACI,MAAA;AACV,MAAA;AAC3B,IAAA;AAEoC,IAAA;AACD,MAAA;AACN,MAAA;AAGG,MAAA;AACE,QAAA;AACF,UAAA;AAC3B,UAAA;AACF,QAAA;AACF,MAAA;AACF,IAAA;AAEyB,IAAA;AACN,MAAA;AACT,MAAA;AACT,IAAA;AACa,EAAA;AACA,IAAA;AACoB,IAAA;AACpC,EAAA;AACF;AF4G2C;AACA;AOharBC;AACS;AACd;AACC;AAKuC;AACnD,EAAA;AAC6B,IAAA;AACC,IAAA;AACM,IAAA;AAE3B,IAAA;AACyB,MAAA;AACpC,IAAA;AAEqC,IAAA;AACL,IAAA;AAEV,IAAA;AACa,IAAA;AAEC,IAAA;AACA,IAAA;AAER,IAAA;AAEV,IAAA;AACW,IAAA;AACb,MAAA;AACiB,IAAA;AACE,MAAA;AACnC,IAAA;AAEgC,IAAA;AACV,MAAA;AACT,QAAA;AACK,QAAA;AAChB,MAAA;AACF,IAAA;AAGuC,IAAA;AAGnB,IAAA;AACa,MAAA;AACjB,MAAA;AACmB,MAAA;AACI,MAAA;AAEhB,MAAA;AACnB,QAAA;AAC6B,QAAA;AACI,QAAA;AACnC,MAAA;AAEW,MAAA;AACb,IAAA;AAGqC,IAAA;AAED,IAAA;AACR,IAAA;AACC,IAAA;AAEf,IAAA;AACa,MAAA;AACd,QAAA;AACA,QAAA;AACqB,QAAA;AAC/B,MAAA;AACH,IAAA;AAG+B,IAAA;AAEzB,MAAA;AAC2B,QAAA;AACZ,QAAA;AACM,UAAA;AACC,UAAA;AACxB,QAAA;AACM,MAAA;AACwB,QAAA;AAChC,MAAA;AACK,IAAA;AAEa,MAAA;AACpB,IAAA;AAEmB,IAAA;AAEM,IAAA;AACd,MAAA;AACT,MAAA;AACS,MAAA;AACV,IAAA;AACa,EAAA;AACqB,IAAA;AACF,IAAA;AACC,IAAA;AACpC,EAAA;AACF;AAEyD;AACnD,EAAA;AACmC,IAAA;AAEE,IAAA;AACH,MAAA;AACpC,IAAA;AAE4B,IAAA;AACD,IAAA;AACD,IAAA;AAEI,IAAA;AACxB,MAAA;AACgC,QAAA;AACH,UAAA;AAC7B,UAAA;AACF,QAAA;AAE+B,QAAA;AACC,QAAA;AAGL,QAAA;AACG,QAAA;AAG1B,QAAA;AAC0B,UAAA;AAEH,UAAA;AACK,YAAA;AAGF,YAAA;AACI,YAAA;AACH,cAAA;AAEL,gBAAA;AACQ,kBAAA;AAChB,oBAAA;AACF,oBAAA;AAAkB,sBAAA;AAA2B,oBAAA;AAAe,oBAAA;AAClE,kBAAA;AACF,gBAAA;AACe,gBAAA;AACjB,cAAA;AACF,YAAA;AACK,UAAA;AACuB,YAAA;AAEH,YAAA;AAEA,YAAA;AAER,cAAA;AACW,gBAAA;AAChB,kBAAA;AACF,kBAAA;AAAkB,oBAAA;AAA2B,kBAAA;AAAe,kBAAA;AAClE,gBAAA;AACF,cAAA;AACoB,cAAA;AACtB,YAAA;AACF,UAAA;AACM,QAAA;AAEK,UAAA;AAEW,YAAA;AACf,UAAA;AAEqB,YAAA;AACX,YAAA;AACe,YAAA;AACA,cAAA;AACX,gBAAA;AACJ,gBAAA;AACb,cAAA;AACF,YAAA;AACe,YAAA;AACa,cAAA;AAC1B,cAAA;AACF,YAAA;AACF,UAAA;AACF,QAAA;AAEqB,QAAA;AACP,MAAA;AACoB,QAAA;AACd,QAAA;AACtB,MAAA;AACF,IAAA;AAEmB,IAAA;AAEM,IAAA;AACd,MAAA;AACT,MAAA;AACqC,MAAA;AACtC,IAAA;AACa,EAAA;AACqB,IAAA;AACD,IAAA;AACpC,EAAA;AACF;AAE+D;AACzD,EAAA;AACiC,IAAA;AAEI,IAAA;AACH,MAAA;AACpC,IAAA;AAEmC,IAAA;AACf,IAAA;AACgB,MAAA;AACpC,IAAA;AAEgC,IAAA;AACK,IAAA;AAEA,IAAA;AACD,MAAA;AACpC,IAAA;AAEI,IAAA;AACwB,MAAA;AACQ,MAAA;AAC5B,IAAA;AAER,IAAA;AAE6B,IAAA;AAEO,IAAA;AACtB,EAAA;AACA,IAAA;AACoB,IAAA;AACpC,EAAA;AACF;AAEyD;AACnD,EAAA;AACiC,IAAA;AAET,IAAA;AACU,MAAA;AACpC,IAAA;AAEsC,IAAA;AAClB,IAAA;AACgB,MAAA;AACpC,IAAA;AAEiC,IAAA;AACC,IAAA;AACH,IAAA;AACG,IAAA;AAEG,IAAA;AACD,MAAA;AACpC,IAAA;AAEI,IAAA;AAC6B,MAAA;AACzB,IAAA;AAC4B,MAAA;AACpC,IAAA;AAEI,IAAA;AAC6B,MAAA;AACG,MAAA;AAC5B,IAAA;AAER,IAAA;AAE4B,IAAA;AACA,IAAA;AACUF,IAAAA;AAEL,IAAA;AAEpB,IAAA;AACiB,MAAA;AACK,MAAA;AACCA,MAAAA;AACb,MAAA;AACA,MAAA;AAEH,MAAA;AACS,QAAA;AAEH,QAAA;AACA,QAAA;AAEY,QAAA;AACD,UAAA;AACA,UAAA;AAEH,UAAA;AAExB,UAAA;AAC4B,YAAA;AACxB,UAAA;AAER,UAAA;AACF,QAAA;AAEkB,QAAA;AACH,QAAA;AACjB,MAAA;AAEmB,MAAA;AACrB,IAAA;AAE+B,IAAA;AACK,IAAA;AACtB,EAAA;AACqB,IAAA;AACD,IAAA;AACpC,EAAA;AACF;AAEuD;AACjD,EAAA;AACmC,IAAA;AAEE,IAAA;AACH,MAAA;AACpC,IAAA;AAE2B,IAAA;AACS,MAAA;AACpC,IAAA;AAEoC,IAAA;AACE,IAAA;AAEFA,IAAAA;AACA,MAAA;AACpC,IAAA;AAEI,IAAA;AAC8B,MAAA;AACF,MAAA;AACM,QAAA;AACpC,MAAA;AACM,IAAA;AAC4B,MAAA;AACpC,IAAA;AAEyB,IAAA;AACC,IAAA;AACE,IAAA;AACV,IAAA;AAEY,IAAA;AACM,MAAA;AACH,MAAA;AACA,MAAA;AACG,MAAA;AAEC,MAAA;AACE,QAAA;AACnC,QAAA;AACF,MAAA;AAEI,MAAA;AAC0B,QAAA;AACtB,MAAA;AAC6B,QAAA;AACnC,QAAA;AACF,MAAA;AAEI,MAAA;AAC6B,QAAA;AACR,QAAA;AACvB,QAAA;AACM,MAAA;AAER,MAAA;AAEI,MAAA;AAC4B,QAAA;AAEF,QAAA;AACM,QAAA;AACC,UAAA;AACJ,UAAA;AACR,UAAA;AACA,UAAA;AAEH,UAAA;AACS,YAAA;AAEH,YAAA;AACA,YAAA;AAEF,YAAA;AACQ,cAAA;AACA,cAAA;AAEN,cAAA;AAEhB,cAAA;AACc,gBAAA;AACV,cAAA;AAER,cAAA;AACF,YAAA;AAEkB,YAAA;AACH,YAAA;AACD,YAAA;AAChB,UAAA;AACF,QAAA;AAEmB,QAAA;AACb,MAAA;AACwB,QAAA;AAChC,MAAA;AACF,IAAA;AAEiB,IAAA;AACI,MAAA;AACrB,IAAA;AAEyB,IAAA;AACI,MAAA;AAC3B,MAAA;AACqC,MAAA;AACtC,IAAA;AACa,EAAA;AACwB,IAAA;AACJ,IAAA;AACpC,EAAA;AACF;APkU2C;AACA;AQ/vBrBE;AACS;AACd;AACE;AAaoC;AACvB,EAAA;AACE,EAAA;AACI,EAAA;AACL,EAAA;AACD,EAAA;AAEK,EAAA;AACb,IAAA;AACT,MAAA;AACK,MAAA;AAChB,IAAA;AACF,EAAA;AAEI,EAAA;AACkC,IAAA;AAEH,IAAA;AACG,MAAA;AACpC,IAAA;AAE4B,IAAA;AAEJ,IAAA;AACd,MAAA;AACsB,MAAA;AACF,MAAA;AAC7B,IAAA;AAEyB,IAAA;AACA,IAAA;AAEQ,IAAA;AACL,MAAA;AACf,MAAA;AACE,QAAA;AACZ,QAAA;AACF,MAAA;AAEa,MAAA;AACS,QAAA;AACpB,QAAA;AACF,MAAA;AAEI,MAAA;AAE6B,QAAA;AAC3B,QAAA;AAC8B,UAAA;AACvB,UAAA;AACc,YAAA;AACX,cAAA;AACqB,cAAA;AACvB,cAAA;AACsB,cAAA;AAC7B,YAAA;AACH,UAAA;AACY,QAAA;AACA,UAAA;AACZ,UAAA;AACF,QAAA;AAGwB,QAAA;AACM,UAAA;AACxB,UAAA;AAC0B,YAAA;AACnB,YAAA;AACc,cAAA;AACX,gBAAA;AACe,gBAAA;AACjB,gBAAA;AACO,gBAAA;AACd,cAAA;AACH,YAAA;AACM,UAAA;AAER,UAAA;AACF,QAAA;AAEU,QAAA;AAGc,QAAA;AACM,UAAA;AACxB,UAAA;AAA2B,YAAA;AAAU,UAAA;AAAe,UAAA;AAC1D,QAAA;AAGI,QAAA;AAAkB,UAAA;AAA2B,QAAA;AAAe,QAAA;AAE5C,QAAA;AACN,MAAA;AACkB,QAAA;AACD,QAAA;AACjC,MAAA;AACF,IAAA;AAEmB,IAAA;AAEM,IAAA;AACd,MAAA;AACT,MAAA;AACqC,MAAA;AACtC,IAAA;AACa,EAAA;AACwB,IAAA;AACJ,IAAA;AACpC,EAAA;AACF;AAE4D;AACtD,EAAA;AACkC,IAAA;AAEH,IAAA;AACG,MAAA;AACpC,IAAA;AAE4B,IAAA;AACC,IAAA;AACH,IAAA;AAEQ,IAAA;AAC5B,MAAA;AACE,QAAA;AACuB,QAAA;AACG,QAAA;AAEC,QAAA;AAE3B,QAAA;AACyB,UAAA;AACrB,QAAA;AACQ,UAAA;AAEmB,YAAA;AAEN,YAAA;AACH,YAAA;AACH,YAAA;AACd,UAAA;AACW,YAAA;AAClB,UAAA;AACF,QAAA;AAE2B,QAAA;AAEb,QAAA;AAEK,UAAA;AACS,UAAA;AACE,UAAA;AAExB,UAAA;AAA8B,YAAA;AAAU,UAAA;AAAe,UAAA;AAC7D,QAAA;AAEiB,QAAA;AACM,QAAA;AACT,MAAA;AACA,QAAA;AACM,QAAA;AACtB,MAAA;AACF,IAAA;AAEmB,IAAA;AAEM,IAAA;AACd,MAAA;AACT,MAAA;AACqC,MAAA;AACtC,IAAA;AACa,EAAA;AACwB,IAAA;AACJ,IAAA;AACpC,EAAA;AACF;AAE+C;AACb,EAAA;AAEE,EAAA;AACR,IAAA;AACc,MAAA;AACA,QAAA;AAA6B;AAAO;AACxE,MAAA;AAEI,MAAA;AAC0B,QAAA;AACC,QAAA;AACH,QAAA;AACQ,QAAA;AAG2C,QAAA;AAE3C,QAAA;AAEnB,UAAA;AAGkB,UAAA;AACH,UAAA;AAGd,UAAA;AACgB,YAAA;AAC9B,UAAA;AACF,QAAA;AAE8B,QAAA;AACI,QAAA;AAEd,QAAA;AACe,UAAA;AACN,UAAA;AAEjB,UAAA;AACF,YAAA;AACO,YAAA;AACb,YAAA;AAC+B,YAAA;AACP,YAAA;AAAA;AACzB,UAAA;AAEG,UAAA;AACsB,YAAA;AACM,YAAA;AACR,YAAA;AAEX,YAAA;AACa,cAAA;AACE,cAAA;AACG,cAAA;AAEL,cAAA;AACK,cAAA;AACR,cAAA;AAEP,cAAA;AACP,gBAAA;AACA,gBAAA;AACA,gBAAA;AACL,cAAA;AACK,YAAA;AACwB,cAAA;AACjB,cAAA;AACd,YAAA;AAE2B,YAAA;AACb,UAAA;AACA,YAAA;AACU,YAAA;AAC1B,UAAA;AACF,QAAA;AAE6B,QAAA;AAGR,QAAA;AACc,QAAA;AAEV,UAAA;AACG,YAAA;AACI,cAAA;AAC5B,YAAA;AACF,UAAA;AACF,QAAA;AAEwC,QAAA;AAClC,UAAA;AACuB,YAAA;AAEI,YAAA;AACE,cAAA;AAEF,cAAA;AACX,cAAA;AAES,cAAA;AACL,gBAAA;AACS,cAAA;AACR,gBAAA;AACG,gBAAA;AAChB,kBAAA;AACc,oBAAA;AACI,oBAAA;AACR,kBAAA;AACE,oBAAA;AAChB,kBAAA;AACF,gBAAA;AACF,cAAA;AACF,YAAA;AACM,UAAA;AAER,UAAA;AACF,QAAA;AAE4B,QAAA;AACxB,QAAA;AACyB,UAAA;AACrB,QAAA;AAER,QAAA;AAE+B,QAAA;AACzB,UAAA;AACuB,YAAA;AACX,YAAA;AAEe,YAAA;AACF,cAAA;AACG,gBAAA;AACR,gBAAA;AACb,cAAA;AACK,gBAAA;AACZ,cAAA;AACF,YAAA;AAEuB,YAAA;AACH,cAAA;AACpB,YAAA;AAEO,YAAA;AACD,UAAA;AACC,YAAA;AACT,UAAA;AACF,QAAA;AAEI,QAAA;AAC6B,UAAA;AACzB,QAAA;AAER,QAAA;AAEmB,QAAA;AAET,QAAA;AACF,UAAA;AACe,UAAA;AACU,UAAA;AAChB,UAAA;AAChB,QAAA;AACa,MAAA;AACA,QAAA;AACa,QAAA;AAC3B,MAAA;AACiB,QAAA;AACnB,MAAA;AACF,IAAA;AACD,EAAA;AAE2B,EAAA;AACjB,IAAA;AACS,MAAA;AACC,MAAA;AACH,MAAA;AAChB,IAAA;AACD,EAAA;AACH;ARsrB2C;AACA;AS9iCZ;AACd;AACC;AAQuB;AACP,EAAA;AAEE,EAAA;AACR,IAAA;AACc,MAAA;AACA,QAAA;AAA6B;AAAO;AACxE,MAAA;AAEI,MAAA;AAC0B,QAAA;AACC,QAAA;AACJ,QAAA;AAC6B,QAAA;AAC5B,QAAA;AAG2C,QAAA;AAEjC,QAAA;AAC9B,UAAA;AACuB,YAAA;AAEI,YAAA;AACE,cAAA;AAEF,cAAA;AACX,cAAA;AAGY,cAAA;AAEH,cAAA;AACC,gBAAA;AACG,cAAA;AACX,gBAAA;AAClB,cAAA;AACF,YAAA;AACM,UAAA;AAER,UAAA;AACF,QAAA;AAE4B,QAAA;AACL,QAAA;AAEA,QAAA;AACW,QAAA;AAEL,QAAA;AACM,UAAA;AACZ,UAAA;AAEX,UAAA;AACF,YAAA;AACO,YAAA;AACb,YAAA;AAC+B,YAAA;AAClB,YAAA;AACd,UAAA;AAG+B,UAAA;AAE9B,YAAA;AACF,UAAA;AAGoB,UAAA;AAEO,YAAA;AACK,YAAA;AAChB,YAAA;AACe,YAAA;AAER,YAAA;AACnB,cAAA;AACyB,cAAA;AAC3B,YAAA;AAG2B,YAAA;AACG,YAAA;AAE1B,YAAA;AACwB,cAAA;AACL,cAAA;AACN,cAAA;AACJ,cAAA;AACA,cAAA;AACC,YAAA;AACE,cAAA;AACF,cAAA;AACZ,cAAA;AACF,YAAA;AACF,UAAA;AAEI,UAAA;AAC0B,YAAA;AAEf,YAAA;AAEc,cAAA;AAEL,cAAA;AAEO,gBAAA;AACpB,cAAA;AACD,gBAAA;AACqBC,kBAAAA;AACN,kBAAA;AACM,oBAAA;AACC,oBAAA;AACxB,kBAAA;AACM,gBAAA;AAEmB,kBAAA;AAC3B,gBAAA;AACF,cAAA;AACK,YAAA;AAEa,cAAA;AACpB,YAAA;AAEyB,YAAA;AACN,YAAA;AACL,UAAA;AACA,YAAA;AACU,YAAA;AAC1B,UAAA;AACF,QAAA;AAEmB,QAAA;AAET,QAAA;AACF,UAAA;AACO,UAAA;AACI,UAAA;AACF,UAAA;AACD,UAAA;AACf,QAAA;AACa,MAAA;AACqB,QAAA;AACR,QAAA;AAC3B,MAAA;AACiB,QAAA;AACnB,MAAA;AACF,IAAA;AACD,EAAA;AAE2B,EAAA;AACjB,IAAA;AACS,MAAA;AACC,MAAA;AACH,MAAA;AAChB,IAAA;AACD,EAAA;AACH;ATugC2C;AACA;AC9pCK;AACjB,EAAA;AACO,IAAA;AACpC,EAAA;AAEiC,EAAA;AACF,EAAA;AAGD,EAAA;AACH,IAAA;AAC3B,EAAA;AAGyC,EAAA;AACd,IAAA;AAC3B,EAAA;AAG8B,EAAA;AACH,IAAA;AAC3B,EAAA;AAG+B,EAAA;AACI,IAAA;AACnC,EAAA;AAGwB,EAAA;AACK,IAAA;AAC7B,EAAA;AAEkC,EAAA;AACpC;AAKiD;AAClB,EAAA;AACO,IAAA;AACpC,EAAA;AAEiC,EAAA;AACF,EAAA;AAGP,EAAA;AACK,IAAA;AAC7B,EAAA;AAGwB,EAAA;AACK,IAAA;AAC7B,EAAA;AAGsB,EAAA;AACK,IAAA;AAC3B,EAAA;AAG2B,EAAA;AACK,IAAA;AAChC,EAAA;AAG6B,EAAA;AACG,IAAA;AAChC,EAAA;AAG+B,EAAA;AACI,IAAA;AACnC,EAAA;AAGwB,EAAA;AACK,IAAA;AAC7B,EAAA;AAGsB,EAAA;AACK,IAAA;AAC3B,EAAA;AAGsB,EAAA;AACI,IAAA;AAC1B,EAAA;AAEkC,EAAA;AACpC;AAKmD;AACpB,EAAA;AACO,IAAA;AACpC,EAAA;AAE2B,EAAA;AAC7B;ADunC2C;AACA;AACA;AACA;AACA","file":"/Users/chrisb/Sites/studio/dist/handlers/index.js","sourcesContent":[null,"import { NextRequest, NextResponse } from 'next/server'\n\n// List handlers\nimport { handleList, handleSearch, handleListFolders, handleCountImages, handleFolderImages } from './list'\n\n// File handlers\nimport { handleUpload, handleDelete, handleCreateFolder, handleRename, handleMove } from './files'\n\n// Image handlers\nimport { handleSync, handleReprocess, handleProcessAllStream } from './images'\n\n// Scan handler\nimport { handleScanStream } from './scan'\n\n/**\n * Unified GET handler for all Studio API routes\n */\nexport async function GET(request: NextRequest) {\n if (process.env.NODE_ENV !== 'development') {\n return NextResponse.json({ error: 'Not available in production' }, { status: 403 })\n }\n\n const pathname = request.nextUrl.pathname\n const route = pathname.replace(/^\\/api\\/studio\\/?/, '')\n\n // Route: /api/studio/list-folders (must come before 'list' check)\n if (route === 'list-folders') {\n return handleListFolders()\n }\n\n // Route: /api/studio/list\n if (route === 'list' || route.startsWith('list')) {\n return handleList(request)\n }\n\n // Route: /api/studio/count-images\n if (route === 'count-images') {\n return handleCountImages()\n }\n\n // Route: /api/studio/folder-images\n if (route === 'folder-images') {\n return handleFolderImages(request)\n }\n\n // Route: /api/studio/search\n if (route === 'search') {\n return handleSearch(request)\n }\n\n return NextResponse.json({ error: 'Not found' }, { status: 404 })\n}\n\n/**\n * Unified POST handler for all Studio API routes\n */\nexport async function POST(request: NextRequest) {\n if (process.env.NODE_ENV !== 'development') {\n return NextResponse.json({ error: 'Not available in production' }, { status: 403 })\n }\n\n const pathname = request.nextUrl.pathname\n const route = pathname.replace(/^\\/api\\/studio\\/?/, '')\n\n // Route: /api/studio/upload\n if (route === 'upload') {\n return handleUpload(request)\n }\n\n // Route: /api/studio/delete\n if (route === 'delete') {\n return handleDelete(request)\n }\n\n // Route: /api/studio/sync\n if (route === 'sync') {\n return handleSync(request)\n }\n\n // Route: /api/studio/reprocess\n if (route === 'reprocess') {\n return handleReprocess(request)\n }\n\n // Route: /api/studio/process-all (streaming)\n if (route === 'process-all') {\n return handleProcessAllStream()\n }\n\n // Route: /api/studio/create-folder\n if (route === 'create-folder') {\n return handleCreateFolder(request)\n }\n\n // Route: /api/studio/rename\n if (route === 'rename') {\n return handleRename(request)\n }\n\n // Route: /api/studio/move\n if (route === 'move') {\n return handleMove(request)\n }\n\n // Route: /api/studio/scan (streaming)\n if (route === 'scan') {\n return handleScanStream()\n }\n\n return NextResponse.json({ error: 'Not found' }, { status: 404 })\n}\n\n/**\n * Unified DELETE handler\n */\nexport async function DELETE(request: NextRequest) {\n if (process.env.NODE_ENV !== 'development') {\n return NextResponse.json({ error: 'Not available in production' }, { status: 403 })\n }\n\n return handleDelete(request)\n}\n","import { NextRequest, NextResponse } from 'next/server'\nimport { promises as fs } from 'fs'\nimport path from 'path'\nimport type { FileItem } from '../types'\nimport { loadMeta, isImageFile } from './utils'\nimport { getThumbnailPath } from '../types'\n\n/**\n * List files and folders from meta\n * Folders are derived from file paths in meta\n */\nexport async function handleList(request: NextRequest) {\n const searchParams = request.nextUrl.searchParams\n const requestedPath = searchParams.get('path') || 'public'\n\n try {\n const meta = await loadMeta()\n const metaKeys = Object.keys(meta)\n \n // If meta is empty, return empty with a flag\n if (metaKeys.length === 0) {\n return NextResponse.json({ items: [], isEmpty: true })\n }\n\n // Normalize the requested path to match meta keys\n // requestedPath is like \"public\" or \"public/photos\"\n // meta keys are like \"/photos/image.jpg\"\n const relativePath = requestedPath.replace(/^public\\/?/, '')\n const pathPrefix = relativePath ? `/${relativePath}/` : '/'\n\n const items: FileItem[] = []\n const seenFolders = new Set<string>()\n\n for (const key of metaKeys) {\n const entry = meta[key]\n \n // Check if this file is under the current path\n if (!key.startsWith(pathPrefix) && pathPrefix !== '/') continue\n if (pathPrefix === '/' && !key.startsWith('/')) continue\n\n // Get the part after the current path\n const remaining = pathPrefix === '/' ? key.slice(1) : key.slice(pathPrefix.length)\n \n // Skip if empty (shouldn't happen)\n if (!remaining) continue\n\n // Check if there's a subfolder\n const slashIndex = remaining.indexOf('/')\n \n if (slashIndex !== -1) {\n // This is in a subfolder - show the folder\n const folderName = remaining.slice(0, slashIndex)\n \n if (!seenFolders.has(folderName)) {\n seenFolders.add(folderName)\n \n // Count files in this folder from meta\n const folderPrefix = pathPrefix === '/' ? `/${folderName}/` : `${pathPrefix}${folderName}/`\n let fileCount = 0\n for (const k of metaKeys) {\n if (k.startsWith(folderPrefix)) fileCount++\n }\n \n items.push({\n name: folderName,\n path: relativePath ? `public/${relativePath}/${folderName}` : `public/${folderName}`,\n type: 'folder',\n fileCount,\n })\n }\n } else {\n // This is a file in the current folder\n const fileName = remaining\n const isImage = isImageFile(fileName)\n const isSynced = entry.c === 1\n \n let thumbnail: string | undefined\n let hasThumbnail = false\n let fileSize: number | undefined\n \n if (isImage && (entry.w || entry.b)) {\n // Has been processed - use thumbnail\n const thumbPath = getThumbnailPath(key, 'sm')\n \n if (isSynced) {\n // CDN thumbnail\n const cdnUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL || process.env.NEXT_PUBLIC_CLOUDFLARE_R2_PUBLIC_URL\n if (cdnUrl) {\n thumbnail = `${cdnUrl}${thumbPath}`\n hasThumbnail = true\n }\n } else {\n // Local thumbnail - check if exists\n const localThumbPath = path.join(process.cwd(), 'public', thumbPath)\n try {\n await fs.access(localThumbPath)\n thumbnail = thumbPath\n hasThumbnail = true\n } catch {\n // Thumbnail doesn't exist yet\n thumbnail = key\n hasThumbnail = false\n }\n }\n } else if (isImage) {\n // Not processed yet - use original\n thumbnail = key\n hasThumbnail = false\n }\n \n // Try to get file size if file exists locally\n if (!isSynced) {\n try {\n const filePath = path.join(process.cwd(), 'public', key)\n const stats = await fs.stat(filePath)\n fileSize = stats.size\n } catch {\n // File might not exist locally (synced)\n }\n }\n \n items.push({\n name: fileName,\n path: relativePath ? `public/${relativePath}/${fileName}` : `public/${fileName}`,\n type: 'file',\n size: fileSize,\n thumbnail,\n hasThumbnail,\n cdnSynced: isSynced,\n dimensions: entry.w && entry.h ? { width: entry.w, height: entry.h } : undefined,\n })\n }\n }\n\n return NextResponse.json({ items })\n } catch (error) {\n console.error('Failed to list directory:', error)\n return NextResponse.json({ error: 'Failed to list directory' }, { status: 500 })\n }\n}\n\nexport async function handleSearch(request: NextRequest) {\n const searchParams = request.nextUrl.searchParams\n const query = searchParams.get('q')?.toLowerCase() || ''\n \n if (query.length < 2) {\n return NextResponse.json({ items: [] })\n }\n\n try {\n const meta = await loadMeta()\n const items: FileItem[] = []\n\n for (const [key, entry] of Object.entries(meta)) {\n // Check if the path matches the query\n if (!key.toLowerCase().includes(query)) continue\n \n const fileName = path.basename(key)\n const relativePath = key.slice(1) // Remove leading /\n const isImage = isImageFile(fileName)\n const isSynced = entry.c === 1\n \n let thumbnail: string | undefined\n let hasThumbnail = false\n \n if (isImage && (entry.w || entry.b)) {\n const thumbPath = getThumbnailPath(key, 'sm')\n \n if (isSynced) {\n const cdnUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL || process.env.NEXT_PUBLIC_CLOUDFLARE_R2_PUBLIC_URL\n if (cdnUrl) {\n thumbnail = `${cdnUrl}${thumbPath}`\n hasThumbnail = true\n }\n } else {\n const localThumbPath = path.join(process.cwd(), 'public', thumbPath)\n try {\n await fs.access(localThumbPath)\n thumbnail = thumbPath\n hasThumbnail = true\n } catch {\n thumbnail = key\n hasThumbnail = false\n }\n }\n } else if (isImage) {\n thumbnail = key\n hasThumbnail = false\n }\n \n items.push({\n name: fileName,\n path: `public/${relativePath}`,\n type: 'file',\n thumbnail,\n hasThumbnail,\n cdnSynced: isSynced,\n dimensions: entry.w && entry.h ? { width: entry.w, height: entry.h } : undefined,\n })\n }\n\n return NextResponse.json({ items })\n } catch (error) {\n console.error('Failed to search:', error)\n return NextResponse.json({ error: 'Failed to search' }, { status: 500 })\n }\n}\n\nexport async function handleListFolders() {\n try {\n const meta = await loadMeta()\n const folderSet = new Set<string>()\n \n // Extract all folder paths from meta keys\n for (const key of Object.keys(meta)) {\n const parts = key.split('/')\n // Build up folder paths: /photos/2024/image.jpg -> photos, photos/2024\n let current = ''\n for (let i = 1; i < parts.length - 1; i++) {\n current = current ? `${current}/${parts[i]}` : parts[i]\n folderSet.add(current)\n }\n }\n \n const folders: { path: string; name: string; depth: number }[] = []\n folders.push({ path: 'public', name: 'public', depth: 0 })\n \n const sortedFolders = Array.from(folderSet).sort()\n for (const folderPath of sortedFolders) {\n const depth = folderPath.split('/').length\n const name = folderPath.split('/').pop() || folderPath\n folders.push({\n path: `public/${folderPath}`,\n name,\n depth\n })\n }\n\n return NextResponse.json({ folders })\n } catch (error) {\n console.error('Failed to list folders:', error)\n return NextResponse.json({ error: 'Failed to list folders' }, { status: 500 })\n }\n}\n\nexport async function handleCountImages() {\n try {\n const meta = await loadMeta()\n const allImages: string[] = []\n\n for (const key of Object.keys(meta)) {\n const fileName = path.basename(key)\n if (isImageFile(fileName)) {\n allImages.push(key.slice(1)) // Remove leading /\n }\n }\n\n return NextResponse.json({\n count: allImages.length,\n images: allImages,\n })\n } catch (error) {\n console.error('Failed to count images:', error)\n return NextResponse.json({ error: 'Failed to count images' }, { status: 500 })\n }\n}\n\nexport async function handleFolderImages(request: NextRequest) {\n try {\n const searchParams = request.nextUrl.searchParams\n const foldersParam = searchParams.get('folders')\n \n if (!foldersParam) {\n return NextResponse.json({ error: 'No folders provided' }, { status: 400 })\n }\n\n const folders = foldersParam.split(',')\n const meta = await loadMeta()\n const allImages: string[] = []\n\n // Convert folder paths to prefixes for matching\n const prefixes = folders.map(f => {\n const rel = f.replace(/^public\\/?/, '')\n return rel ? `/${rel}/` : '/'\n })\n\n for (const key of Object.keys(meta)) {\n const fileName = path.basename(key)\n if (!isImageFile(fileName)) continue\n \n // Check if this image is in one of the requested folders\n for (const prefix of prefixes) {\n if (key.startsWith(prefix) || (prefix === '/' && key.startsWith('/'))) {\n allImages.push(key.slice(1)) // Remove leading /\n break\n }\n }\n }\n\n return NextResponse.json({\n count: allImages.length,\n images: allImages,\n })\n } catch (error) {\n console.error('Failed to get folder images:', error)\n return NextResponse.json({ error: 'Failed to get folder images' }, { status: 500 })\n }\n}\n","import { promises as fs } from 'fs'\nimport path from 'path'\nimport type { LeanMeta } from '../../types'\n\nexport async function loadMeta(): Promise<LeanMeta> {\n const metaPath = path.join(process.cwd(), '_data', '_meta.json')\n \n try {\n const content = await fs.readFile(metaPath, 'utf-8')\n return JSON.parse(content) as LeanMeta\n } catch {\n return {}\n }\n}\n\nexport async function saveMeta(meta: LeanMeta): Promise<void> {\n const dataDir = path.join(process.cwd(), '_data')\n await fs.mkdir(dataDir, { recursive: true })\n const metaPath = path.join(dataDir, '_meta.json')\n await fs.writeFile(metaPath, JSON.stringify(meta, null, 2))\n}\n","import { promises as fs } from 'fs'\nimport path from 'path'\n\nexport function isImageFile(filename: string): boolean {\n const ext = path.extname(filename).toLowerCase()\n return ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.ico', '.bmp', '.tiff', '.tif'].includes(ext)\n}\n\nexport function isMediaFile(filename: string): boolean {\n const ext = path.extname(filename).toLowerCase()\n // Images\n if (['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.ico', '.bmp', '.tiff', '.tif'].includes(ext)) return true\n // Videos\n if (['.mp4', '.webm', '.mov', '.avi', '.mkv', '.m4v'].includes(ext)) return true\n // Audio\n if (['.mp3', '.wav', '.ogg', '.m4a', '.flac', '.aac'].includes(ext)) return true\n // Documents/PDFs\n if (['.pdf'].includes(ext)) return true\n return false\n}\n\nexport function getContentType(filePath: string): string {\n const ext = path.extname(filePath).toLowerCase()\n switch (ext) {\n case '.jpg':\n case '.jpeg':\n return 'image/jpeg'\n case '.png':\n return 'image/png'\n case '.gif':\n return 'image/gif'\n case '.webp':\n return 'image/webp'\n case '.svg':\n return 'image/svg+xml'\n default:\n return 'application/octet-stream'\n }\n}\n\nexport async function getFolderStats(folderPath: string): Promise<{ fileCount: number; totalSize: number }> {\n let fileCount = 0\n let totalSize = 0\n\n async function scanFolder(dir: string): Promise<void> {\n try {\n const entries = await fs.readdir(dir, { withFileTypes: true })\n for (const entry of entries) {\n if (entry.name.startsWith('.')) continue\n const fullPath = path.join(dir, entry.name)\n if (entry.isDirectory()) {\n await scanFolder(fullPath)\n } else if (isMediaFile(entry.name)) {\n fileCount++\n const stats = await fs.stat(fullPath)\n totalSize += stats.size\n }\n }\n } catch { /* ignore errors */ }\n }\n\n await scanFolder(folderPath)\n return { fileCount, totalSize }\n}\n","import { promises as fs } from 'fs'\nimport path from 'path'\nimport sharp from 'sharp'\nimport { encode } from 'blurhash'\nimport type { MetaEntry } from '../../types'\n\nexport const DEFAULT_SIZES: Record<string, { width: number; suffix: string }> = {\n small: { width: 300, suffix: '-sm' },\n medium: { width: 700, suffix: '-md' },\n large: { width: 1400, suffix: '-lg' },\n}\n\nexport async function processImage(\n buffer: Buffer,\n imageKey: string\n): Promise<MetaEntry> {\n const sharpInstance = sharp(buffer)\n const metadata = await sharpInstance.metadata()\n const originalWidth = metadata.width || 0\n const originalHeight = metadata.height || 0\n\n // Remove leading slash for path operations\n const keyWithoutSlash = imageKey.startsWith('/') ? imageKey.slice(1) : imageKey\n const baseName = path.basename(keyWithoutSlash, path.extname(keyWithoutSlash))\n const ext = path.extname(keyWithoutSlash).toLowerCase()\n const imageDir = path.dirname(keyWithoutSlash)\n\n const imagesPath = path.join(process.cwd(), 'public', 'images', imageDir === '.' ? '' : imageDir)\n await fs.mkdir(imagesPath, { recursive: true })\n\n const isPng = ext === '.png'\n const outputExt = isPng ? '.png' : '.jpg'\n \n // Generate full size\n const fullFileName = imageDir === '.' ? `${baseName}${outputExt}` : `${imageDir}/${baseName}${outputExt}`\n const fullPath = path.join(process.cwd(), 'public', 'images', fullFileName)\n \n if (isPng) {\n await sharp(buffer).png({ quality: 85 }).toFile(fullPath)\n } else {\n await sharp(buffer).jpeg({ quality: 85 }).toFile(fullPath)\n }\n\n // Generate thumbnail sizes\n for (const [, sizeConfig] of Object.entries(DEFAULT_SIZES)) {\n const { width: maxWidth, suffix } = sizeConfig\n if (originalWidth <= maxWidth) {\n continue // Skip if original is smaller than this size\n }\n\n const ratio = originalHeight / originalWidth\n const newHeight = Math.round(maxWidth * ratio)\n const sizeFileName = `${baseName}${suffix}${outputExt}`\n const sizeFilePath = imageDir === '.' ? sizeFileName : `${imageDir}/${sizeFileName}`\n const sizePath = path.join(process.cwd(), 'public', 'images', sizeFilePath)\n\n if (isPng) {\n await sharp(buffer).resize(maxWidth, newHeight).png({ quality: 80 }).toFile(sizePath)\n } else {\n await sharp(buffer).resize(maxWidth, newHeight).jpeg({ quality: 80 }).toFile(sizePath)\n }\n }\n\n // Generate blurhash\n const { data, info } = await sharp(buffer)\n .resize(32, 32, { fit: 'inside' })\n .ensureAlpha()\n .raw()\n .toBuffer({ resolveWithObject: true })\n\n const blurhash = encode(new Uint8ClampedArray(data), info.width, info.height, 4, 4)\n\n return {\n w: originalWidth,\n h: originalHeight,\n b: blurhash,\n }\n}\n","import { promises as fs } from 'fs'\nimport path from 'path'\nimport { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'\nimport { getAllThumbnailPaths } from '../../types'\nimport { getContentType } from './files'\n\nfunction getR2Client() {\n const accountId = process.env.CLOUDFLARE_R2_ACCOUNT_ID\n const accessKeyId = process.env.CLOUDFLARE_R2_ACCESS_KEY_ID\n const secretAccessKey = process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY\n\n if (!accountId || !accessKeyId || !secretAccessKey) {\n throw new Error('R2 not configured')\n }\n\n return new S3Client({\n region: 'auto',\n endpoint: `https://${accountId}.r2.cloudflarestorage.com`,\n credentials: { accessKeyId, secretAccessKey },\n })\n}\n\nexport async function downloadFromCdn(originalPath: string): Promise<Buffer> {\n const bucketName = process.env.CLOUDFLARE_R2_BUCKET_NAME\n if (!bucketName) throw new Error('R2 bucket not configured')\n\n const r2 = getR2Client()\n\n const response = await r2.send(\n new GetObjectCommand({\n Bucket: bucketName,\n Key: originalPath.replace(/^\\//, ''),\n })\n )\n\n const stream = response.Body as NodeJS.ReadableStream\n const chunks: Buffer[] = []\n for await (const chunk of stream) {\n chunks.push(Buffer.from(chunk))\n }\n return Buffer.concat(chunks)\n}\n\nexport async function uploadToCdn(imageKey: string): Promise<void> {\n const bucketName = process.env.CLOUDFLARE_R2_BUCKET_NAME\n if (!bucketName) throw new Error('R2 bucket not configured')\n\n const r2 = getR2Client()\n\n // Upload all thumbnail sizes derived from imageKey\n for (const thumbPath of getAllThumbnailPaths(imageKey)) {\n const localPath = path.join(process.cwd(), 'public', thumbPath)\n try {\n const fileBuffer = await fs.readFile(localPath)\n await r2.send(\n new PutObjectCommand({\n Bucket: bucketName,\n Key: thumbPath.replace(/^\\//, ''),\n Body: fileBuffer,\n ContentType: getContentType(thumbPath),\n })\n )\n } catch {\n // File might not exist (e.g., if image is smaller than thumbnail size)\n }\n }\n}\n\nexport async function deleteLocalThumbnails(imageKey: string): Promise<void> {\n for (const thumbPath of getAllThumbnailPaths(imageKey)) {\n const localPath = path.join(process.cwd(), 'public', thumbPath)\n try {\n await fs.unlink(localPath)\n } catch {\n // File might not exist\n }\n }\n}\n","import { NextRequest, NextResponse } from 'next/server'\nimport { promises as fs } from 'fs'\nimport path from 'path'\nimport sharp from 'sharp'\nimport type { MetaEntry } from '../types'\nimport { getAllThumbnailPaths } from '../types'\nimport { loadMeta, saveMeta, isImageFile, isMediaFile } from './utils'\n\nexport async function handleUpload(request: NextRequest) {\n try {\n const formData = await request.formData()\n const file = formData.get('file') as File | null\n const targetPath = formData.get('path') as string || 'public'\n\n if (!file) {\n return NextResponse.json({ error: 'No file provided' }, { status: 400 })\n }\n\n const bytes = await file.arrayBuffer()\n const buffer = Buffer.from(bytes)\n\n const fileName = file.name\n const ext = path.extname(fileName).toLowerCase()\n\n const isImage = isImageFile(fileName)\n const isMedia = isMediaFile(fileName)\n\n const meta = await loadMeta()\n\n let relativeDir = ''\n if (targetPath === 'public') {\n relativeDir = ''\n } else if (targetPath.startsWith('public/')) {\n relativeDir = targetPath.replace('public/', '')\n }\n \n if (relativeDir === 'images' || relativeDir.startsWith('images/')) {\n return NextResponse.json(\n { error: 'Cannot upload to images/ folder. Upload to public/ instead - thumbnails are generated automatically.' },\n { status: 400 }\n )\n }\n\n // Build the meta key\n let imageKey = '/' + (relativeDir ? `${relativeDir}/${fileName}` : fileName)\n\n // Check for collision - rename if needed\n if (meta[imageKey]) {\n const baseName = path.basename(fileName, ext)\n let counter = 1\n let newFileName = `${baseName}-${counter}${ext}`\n let newKey = '/' + (relativeDir ? `${relativeDir}/${newFileName}` : newFileName)\n \n while (meta[newKey]) {\n counter++\n newFileName = `${baseName}-${counter}${ext}`\n newKey = '/' + (relativeDir ? `${relativeDir}/${newFileName}` : newFileName)\n }\n \n imageKey = newKey\n }\n\n // Extract actual filename from key\n const actualFileName = path.basename(imageKey)\n \n const uploadDir = path.join(process.cwd(), 'public', relativeDir)\n await fs.mkdir(uploadDir, { recursive: true })\n await fs.writeFile(path.join(uploadDir, actualFileName), buffer)\n\n if (!isMedia) {\n return NextResponse.json({ \n success: true, \n message: 'File uploaded (not a media file)',\n path: `public/${relativeDir ? relativeDir + '/' : ''}${actualFileName}`\n })\n }\n\n // Add to meta\n if (isImage && ext !== '.svg') {\n // Read dimensions for images\n try {\n const metadata = await sharp(buffer).metadata()\n meta[imageKey] = {\n w: metadata.width || 0,\n h: metadata.height || 0,\n }\n } catch {\n meta[imageKey] = { w: 0, h: 0 }\n }\n } else {\n // Non-image media or SVG\n meta[imageKey] = {}\n }\n\n await saveMeta(meta)\n\n return NextResponse.json({ \n success: true, \n imageKey,\n message: 'File uploaded. Run \"Process Images\" to generate thumbnails.'\n })\n } catch (error) {\n console.error('Failed to upload:', error)\n const message = error instanceof Error ? error.message : 'Unknown error'\n return NextResponse.json({ error: `Failed to upload file: ${message}` }, { status: 500 })\n }\n}\n\nexport async function handleDelete(request: NextRequest) {\n try {\n const { paths } = await request.json() as { paths: string[] }\n\n if (!paths || !Array.isArray(paths) || paths.length === 0) {\n return NextResponse.json({ error: 'No paths provided' }, { status: 400 })\n }\n\n const meta = await loadMeta()\n const deleted: string[] = []\n const errors: string[] = []\n\n for (const itemPath of paths) {\n try {\n if (!itemPath.startsWith('public/')) {\n errors.push(`Invalid path: ${itemPath}`)\n continue\n }\n\n const absolutePath = path.join(process.cwd(), itemPath)\n const imageKey = '/' + itemPath.replace(/^public\\//, '')\n \n // Check if this is in meta (could be synced with no local file)\n const entry = meta[imageKey]\n const isSynced = entry?.c === 1\n \n // Try to delete local file/folder\n try {\n const stats = await fs.stat(absolutePath)\n\n if (stats.isDirectory()) {\n await fs.rm(absolutePath, { recursive: true })\n \n // Remove all meta entries under this folder\n const prefix = imageKey + '/'\n for (const key of Object.keys(meta)) {\n if (key.startsWith(prefix) || key === imageKey) {\n // Also delete local thumbnails if not synced\n if (!meta[key].c) {\n for (const thumbPath of getAllThumbnailPaths(key)) {\n const absoluteThumbPath = path.join(process.cwd(), 'public', thumbPath)\n try { await fs.unlink(absoluteThumbPath) } catch { /* ignore */ }\n }\n }\n delete meta[key]\n }\n }\n } else {\n await fs.unlink(absolutePath)\n\n const isInImagesFolder = itemPath.startsWith('public/images/')\n \n if (!isInImagesFolder && entry) {\n // Delete local thumbnails if not synced\n if (!isSynced) {\n for (const thumbPath of getAllThumbnailPaths(imageKey)) {\n const absoluteThumbPath = path.join(process.cwd(), 'public', thumbPath)\n try { await fs.unlink(absoluteThumbPath) } catch { /* ignore */ }\n }\n }\n delete meta[imageKey]\n }\n }\n } catch {\n // File doesn't exist locally - might be synced\n if (entry) {\n // Just remove from meta (file is on CDN)\n delete meta[imageKey]\n } else {\n // Check if it's a folder prefix in meta\n const prefix = imageKey + '/'\n let foundAny = false\n for (const key of Object.keys(meta)) {\n if (key.startsWith(prefix)) {\n delete meta[key]\n foundAny = true\n }\n }\n if (!foundAny) {\n errors.push(`Not found: ${itemPath}`)\n continue\n }\n }\n }\n\n deleted.push(itemPath)\n } catch (error) {\n console.error(`Failed to delete ${itemPath}:`, error)\n errors.push(itemPath)\n }\n }\n\n await saveMeta(meta)\n\n return NextResponse.json({\n success: true,\n deleted,\n errors: errors.length > 0 ? errors : undefined,\n })\n } catch (error) {\n console.error('Failed to delete:', error)\n return NextResponse.json({ error: 'Failed to delete files' }, { status: 500 })\n }\n}\n\nexport async function handleCreateFolder(request: NextRequest) {\n try {\n const { parentPath, name } = await request.json()\n\n if (!name || typeof name !== 'string') {\n return NextResponse.json({ error: 'Folder name is required' }, { status: 400 })\n }\n\n const sanitizedName = name.replace(/[<>:\"/\\\\|?*]/g, '').trim()\n if (!sanitizedName) {\n return NextResponse.json({ error: 'Invalid folder name' }, { status: 400 })\n }\n\n const safePath = (parentPath || 'public').replace(/\\.\\./g, '')\n const folderPath = path.join(process.cwd(), safePath, sanitizedName)\n\n if (!folderPath.startsWith(path.join(process.cwd(), 'public'))) {\n return NextResponse.json({ error: 'Invalid path' }, { status: 400 })\n }\n\n try {\n await fs.access(folderPath)\n return NextResponse.json({ error: 'A folder with this name already exists' }, { status: 400 })\n } catch {\n // Good - folder doesn't exist\n }\n\n await fs.mkdir(folderPath, { recursive: true })\n\n return NextResponse.json({ success: true, path: path.join(safePath, sanitizedName) })\n } catch (error) {\n console.error('Failed to create folder:', error)\n return NextResponse.json({ error: 'Failed to create folder' }, { status: 500 })\n }\n}\n\nexport async function handleRename(request: NextRequest) {\n try {\n const { oldPath, newName } = await request.json()\n\n if (!oldPath || !newName) {\n return NextResponse.json({ error: 'Path and new name are required' }, { status: 400 })\n }\n\n const sanitizedName = newName.replace(/[<>:\"/\\\\|?*]/g, '').trim()\n if (!sanitizedName) {\n return NextResponse.json({ error: 'Invalid name' }, { status: 400 })\n }\n\n const safePath = oldPath.replace(/\\.\\./g, '')\n const absoluteOldPath = path.join(process.cwd(), safePath)\n const parentDir = path.dirname(absoluteOldPath)\n const absoluteNewPath = path.join(parentDir, sanitizedName)\n\n if (!absoluteOldPath.startsWith(path.join(process.cwd(), 'public'))) {\n return NextResponse.json({ error: 'Invalid path' }, { status: 400 })\n }\n\n try {\n await fs.access(absoluteOldPath)\n } catch {\n return NextResponse.json({ error: 'File or folder not found' }, { status: 404 })\n }\n\n try {\n await fs.access(absoluteNewPath)\n return NextResponse.json({ error: 'An item with this name already exists' }, { status: 400 })\n } catch {\n // Good - new path doesn't exist\n }\n\n const stats = await fs.stat(absoluteOldPath)\n const isFile = stats.isFile()\n const isImage = isFile && isImageFile(path.basename(oldPath))\n\n await fs.rename(absoluteOldPath, absoluteNewPath)\n\n if (isImage) {\n const meta = await loadMeta()\n const oldRelativePath = safePath.replace(/^public\\//, '')\n const newRelativePath = path.join(path.dirname(oldRelativePath), sanitizedName)\n const oldKey = '/' + oldRelativePath\n const newKey = '/' + newRelativePath\n\n if (meta[oldKey]) {\n const entry = meta[oldKey]\n\n const oldThumbPaths = getAllThumbnailPaths(oldKey)\n const newThumbPaths = getAllThumbnailPaths(newKey)\n\n for (let i = 0; i < oldThumbPaths.length; i++) {\n const oldThumbPath = path.join(process.cwd(), 'public', oldThumbPaths[i])\n const newThumbPath = path.join(process.cwd(), 'public', newThumbPaths[i])\n \n await fs.mkdir(path.dirname(newThumbPath), { recursive: true })\n \n try {\n await fs.rename(oldThumbPath, newThumbPath)\n } catch {\n // Thumbnail might not exist\n }\n }\n\n delete meta[oldKey]\n meta[newKey] = entry\n }\n\n await saveMeta(meta)\n }\n\n const newPath = path.join(path.dirname(safePath), sanitizedName)\n return NextResponse.json({ success: true, newPath })\n } catch (error) {\n console.error('Failed to rename:', error)\n return NextResponse.json({ error: 'Failed to rename' }, { status: 500 })\n }\n}\n\nexport async function handleMove(request: NextRequest) {\n try {\n const { paths, destination } = await request.json()\n\n if (!paths || !Array.isArray(paths) || paths.length === 0) {\n return NextResponse.json({ error: 'Paths are required' }, { status: 400 })\n }\n\n if (!destination || typeof destination !== 'string') {\n return NextResponse.json({ error: 'Destination is required' }, { status: 400 })\n }\n\n const safeDestination = destination.replace(/\\.\\./g, '')\n const absoluteDestination = path.join(process.cwd(), safeDestination)\n\n if (!absoluteDestination.startsWith(path.join(process.cwd(), 'public'))) {\n return NextResponse.json({ error: 'Invalid destination' }, { status: 400 })\n }\n\n try {\n const destStats = await fs.stat(absoluteDestination)\n if (!destStats.isDirectory()) {\n return NextResponse.json({ error: 'Destination is not a folder' }, { status: 400 })\n }\n } catch {\n return NextResponse.json({ error: 'Destination folder not found' }, { status: 404 })\n }\n\n const moved: string[] = []\n const errors: string[] = []\n const meta = await loadMeta()\n let metaChanged = false\n\n for (const itemPath of paths) {\n const safePath = itemPath.replace(/\\.\\./g, '')\n const absolutePath = path.join(process.cwd(), safePath)\n const itemName = path.basename(safePath)\n const newAbsolutePath = path.join(absoluteDestination, itemName)\n\n if (absoluteDestination.startsWith(absolutePath + path.sep)) {\n errors.push(`Cannot move ${itemName} into itself`)\n continue\n }\n\n try {\n await fs.access(absolutePath)\n } catch {\n errors.push(`${itemName} not found`)\n continue\n }\n\n try {\n await fs.access(newAbsolutePath)\n errors.push(`${itemName} already exists in destination`)\n continue\n } catch {\n // Good - doesn't exist\n }\n\n try {\n await fs.rename(absolutePath, newAbsolutePath)\n\n const stats = await fs.stat(newAbsolutePath)\n if (stats.isFile() && isImageFile(itemName)) {\n const oldRelativePath = safePath.replace(/^public\\//, '')\n const newRelativePath = path.join(safeDestination.replace(/^public\\//, ''), itemName)\n const oldKey = '/' + oldRelativePath\n const newKey = '/' + newRelativePath\n\n if (meta[oldKey]) {\n const entry = meta[oldKey]\n\n const oldThumbPaths = getAllThumbnailPaths(oldKey)\n const newThumbPaths = getAllThumbnailPaths(newKey)\n\n for (let i = 0; i < oldThumbPaths.length; i++) {\n const oldThumbPath = path.join(process.cwd(), 'public', oldThumbPaths[i])\n const newThumbPath = path.join(process.cwd(), 'public', newThumbPaths[i])\n \n await fs.mkdir(path.dirname(newThumbPath), { recursive: true })\n\n try {\n await fs.rename(oldThumbPath, newThumbPath)\n } catch {\n // Thumbnail might not exist\n }\n }\n\n delete meta[oldKey]\n meta[newKey] = entry\n metaChanged = true\n }\n }\n\n moved.push(itemPath)\n } catch {\n errors.push(`Failed to move ${itemName}`)\n }\n }\n\n if (metaChanged) {\n await saveMeta(meta)\n }\n\n return NextResponse.json({\n success: errors.length === 0,\n moved,\n errors: errors.length > 0 ? errors : undefined\n })\n } catch (error) {\n console.error('Failed to move:', error)\n return NextResponse.json({ error: 'Failed to move items' }, { status: 500 })\n }\n}\n","import { NextRequest, NextResponse } from 'next/server'\nimport { promises as fs } from 'fs'\nimport path from 'path'\nimport { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'\nimport { getAllThumbnailPaths } from '../types'\nimport {\n loadMeta,\n saveMeta,\n isImageFile,\n getContentType,\n processImage,\n downloadFromCdn,\n uploadToCdn,\n deleteLocalThumbnails,\n} from './utils'\n\nexport async function handleSync(request: NextRequest) {\n const accountId = process.env.CLOUDFLARE_R2_ACCOUNT_ID\n const accessKeyId = process.env.CLOUDFLARE_R2_ACCESS_KEY_ID\n const secretAccessKey = process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY\n const bucketName = process.env.CLOUDFLARE_R2_BUCKET_NAME\n const publicUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL\n\n if (!accountId || !accessKeyId || !secretAccessKey || !bucketName || !publicUrl) {\n return NextResponse.json(\n { error: 'R2 not configured. Set CLOUDFLARE_R2_* environment variables.' },\n { status: 400 }\n )\n }\n\n try {\n const { imageKeys } = await request.json() as { imageKeys: string[] }\n\n if (!imageKeys || !Array.isArray(imageKeys) || imageKeys.length === 0) {\n return NextResponse.json({ error: 'No image keys provided' }, { status: 400 })\n }\n\n const meta = await loadMeta()\n\n const r2 = new S3Client({\n region: 'auto',\n endpoint: `https://${accountId}.r2.cloudflarestorage.com`,\n credentials: { accessKeyId, secretAccessKey },\n })\n\n const synced: string[] = []\n const errors: string[] = []\n\n for (const imageKey of imageKeys) {\n const entry = meta[imageKey]\n if (!entry) {\n errors.push(`Image not found in meta: ${imageKey}. Run Scan first.`)\n continue\n }\n\n if (entry.c) {\n synced.push(imageKey)\n continue\n }\n\n try {\n // Upload original file first\n const originalLocalPath = path.join(process.cwd(), 'public', imageKey)\n try {\n const originalBuffer = await fs.readFile(originalLocalPath)\n await r2.send(\n new PutObjectCommand({\n Bucket: bucketName,\n Key: imageKey.replace(/^\\//, ''),\n Body: originalBuffer,\n ContentType: getContentType(imageKey),\n })\n )\n } catch (err) {\n errors.push(`Original file not found: ${imageKey}`)\n continue\n }\n\n // Upload thumbnails\n for (const thumbPath of getAllThumbnailPaths(imageKey)) {\n const localPath = path.join(process.cwd(), 'public', thumbPath)\n try {\n const fileBuffer = await fs.readFile(localPath)\n await r2.send(\n new PutObjectCommand({\n Bucket: bucketName,\n Key: thumbPath.replace(/^\\//, ''),\n Body: fileBuffer,\n ContentType: getContentType(thumbPath),\n })\n )\n } catch {\n // Thumbnail might not exist (not processed yet)\n }\n }\n\n entry.c = 1\n\n // Delete local thumbnails\n for (const thumbPath of getAllThumbnailPaths(imageKey)) {\n const localPath = path.join(process.cwd(), 'public', thumbPath)\n try { await fs.unlink(localPath) } catch { /* ignore */ }\n }\n\n // Delete local original\n try { await fs.unlink(originalLocalPath) } catch { /* ignore */ }\n\n synced.push(imageKey)\n } catch (error) {\n console.error(`Failed to sync ${imageKey}:`, error)\n errors.push(`Failed to sync: ${imageKey}`)\n }\n }\n\n await saveMeta(meta)\n\n return NextResponse.json({\n success: true,\n synced,\n errors: errors.length > 0 ? errors : undefined,\n })\n } catch (error) {\n console.error('Failed to sync:', error)\n return NextResponse.json({ error: 'Failed to sync to CDN' }, { status: 500 })\n }\n}\n\nexport async function handleReprocess(request: NextRequest) {\n try {\n const { imageKeys } = await request.json() as { imageKeys: string[] }\n\n if (!imageKeys || !Array.isArray(imageKeys) || imageKeys.length === 0) {\n return NextResponse.json({ error: 'No image keys provided' }, { status: 400 })\n }\n\n const meta = await loadMeta()\n const processed: string[] = []\n const errors: string[] = []\n\n for (const imageKey of imageKeys) {\n try {\n let buffer: Buffer\n const entry = meta[imageKey]\n const isSynced = entry?.c === 1\n \n const originalPath = path.join(process.cwd(), 'public', imageKey)\n \n try {\n buffer = await fs.readFile(originalPath)\n } catch {\n if (isSynced) {\n // Download original from CDN to local path\n buffer = await downloadFromCdn(imageKey)\n // Save to local path for processing\n const dir = path.dirname(originalPath)\n await fs.mkdir(dir, { recursive: true })\n await fs.writeFile(originalPath, buffer)\n } else {\n throw new Error(`File not found: ${imageKey}`)\n }\n }\n\n const updatedEntry = await processImage(buffer, imageKey)\n \n if (isSynced) {\n // Re-upload to CDN and clean up local files\n updatedEntry.c = 1\n await uploadToCdn(imageKey)\n await deleteLocalThumbnails(imageKey)\n // Delete local original\n try { await fs.unlink(originalPath) } catch { /* ignore */ }\n }\n \n meta[imageKey] = updatedEntry\n processed.push(imageKey)\n } catch (error) {\n console.error(`Failed to reprocess ${imageKey}:`, error)\n errors.push(imageKey)\n }\n }\n\n await saveMeta(meta)\n\n return NextResponse.json({\n success: true,\n processed,\n errors: errors.length > 0 ? errors : undefined,\n })\n } catch (error) {\n console.error('Failed to reprocess:', error)\n return NextResponse.json({ error: 'Failed to reprocess images' }, { status: 500 })\n }\n}\n\nexport async function handleProcessAllStream() {\n const encoder = new TextEncoder()\n \n const stream = new ReadableStream({\n async start(controller) {\n const sendEvent = (data: object) => {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\\n\\n`))\n }\n\n try {\n const meta = await loadMeta()\n const processed: string[] = []\n const errors: string[] = []\n const orphansRemoved: string[] = []\n\n // Get all images from meta that need processing (not synced, no blur yet)\n const imagesToProcess: Array<{ key: string; entry: typeof meta[string] }> = []\n \n for (const [key, entry] of Object.entries(meta)) {\n // Skip pushed images - they're already processed and on CDN\n if (entry.c) continue\n \n // Skip non-images (no w/h means it was added as non-image or SVG)\n const fileName = path.basename(key)\n if (!isImageFile(fileName)) continue\n \n // Check if needs processing (no b = not processed yet)\n if (!entry.b) {\n imagesToProcess.push({ key, entry })\n }\n }\n\n const total = imagesToProcess.length\n sendEvent({ type: 'start', total })\n\n for (let i = 0; i < imagesToProcess.length; i++) {\n const { key } = imagesToProcess[i]\n const fullPath = path.join(process.cwd(), 'public', key)\n \n sendEvent({ \n type: 'progress', \n current: i + 1, \n total, \n percent: Math.round(((i + 1) / total) * 100),\n currentFile: key.slice(1) // Remove leading /\n })\n\n try {\n const buffer = await fs.readFile(fullPath)\n const ext = path.extname(key).toLowerCase()\n const isSvg = ext === '.svg'\n\n if (isSvg) {\n const imageDir = path.dirname(key.slice(1))\n const imagesPath = path.join(process.cwd(), 'public', 'images', imageDir === '.' ? '' : imageDir)\n await fs.mkdir(imagesPath, { recursive: true })\n \n const fileName = path.basename(key)\n const destPath = path.join(imagesPath, fileName)\n await fs.writeFile(destPath, buffer)\n\n meta[key] = {\n w: 0,\n h: 0,\n b: '',\n }\n } else {\n const processedEntry = await processImage(buffer, key)\n meta[key] = processedEntry\n }\n\n processed.push(key.slice(1))\n } catch (error) {\n console.error(`Failed to process ${key}:`, error)\n errors.push(key.slice(1))\n }\n }\n\n sendEvent({ type: 'cleanup', message: 'Removing orphaned thumbnails...' })\n \n // Build set of expected thumbnail paths\n const trackedPaths = new Set<string>()\n for (const imageKey of Object.keys(meta)) {\n // Only track local thumbnails (not pushed to CDN)\n if (!meta[imageKey].c) {\n for (const thumbPath of getAllThumbnailPaths(imageKey)) {\n trackedPaths.add(thumbPath)\n }\n }\n }\n\n async function findOrphans(dir: string, relativePath: string = ''): Promise<void> {\n try {\n const entries = await fs.readdir(dir, { withFileTypes: true })\n \n for (const entry of entries) {\n if (entry.name.startsWith('.')) continue\n\n const fullPath = path.join(dir, entry.name)\n const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name\n\n if (entry.isDirectory()) {\n await findOrphans(fullPath, relPath)\n } else if (isImageFile(entry.name)) {\n const publicPath = `/images/${relPath}`\n if (!trackedPaths.has(publicPath)) {\n try {\n await fs.unlink(fullPath)\n orphansRemoved.push(publicPath)\n } catch (err) {\n console.error(`Failed to remove orphan ${publicPath}:`, err)\n }\n }\n }\n }\n } catch {\n // Directory might not exist\n }\n }\n\n const imagesDir = path.join(process.cwd(), 'public', 'images')\n try {\n await findOrphans(imagesDir)\n } catch {\n // images dir might not exist\n }\n\n async function removeEmptyDirs(dir: string): Promise<boolean> {\n try {\n const entries = await fs.readdir(dir, { withFileTypes: true })\n let isEmpty = true\n\n for (const entry of entries) {\n if (entry.isDirectory()) {\n const subDirEmpty = await removeEmptyDirs(path.join(dir, entry.name))\n if (!subDirEmpty) isEmpty = false\n } else {\n isEmpty = false\n }\n }\n\n if (isEmpty && dir !== imagesDir) {\n await fs.rmdir(dir)\n }\n\n return isEmpty\n } catch {\n return true\n }\n }\n\n try {\n await removeEmptyDirs(imagesDir)\n } catch {\n // images dir might not exist\n }\n \n await saveMeta(meta)\n\n sendEvent({ \n type: 'complete', \n processed: processed.length, \n orphansRemoved: orphansRemoved.length,\n errors: errors.length,\n })\n } catch (error) {\n console.error('Failed to process all:', error)\n sendEvent({ type: 'error', message: 'Failed to process images' })\n } finally {\n controller.close()\n }\n }\n })\n\n return new Response(stream, {\n headers: {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache',\n 'Connection': 'keep-alive',\n },\n })\n}\n","import { promises as fs } from 'fs'\nimport path from 'path'\nimport sharp from 'sharp'\nimport { loadMeta, saveMeta, isMediaFile, isImageFile } from './utils'\n\n/**\n * Streaming scan handler - scans filesystem for new files not in meta\n * For images, reads dimensions (w/h)\n * Handles collisions by renaming files with -1, -2, etc.\n */\nexport async function handleScanStream() {\n const encoder = new TextEncoder()\n \n const stream = new ReadableStream({\n async start(controller) {\n const sendEvent = (data: object) => {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\\n\\n`))\n }\n\n try {\n const meta = await loadMeta()\n const existingKeys = new Set(Object.keys(meta))\n const added: string[] = []\n const renamed: Array<{ from: string; to: string }> = []\n const errors: string[] = []\n\n // Collect all files first\n const allFiles: Array<{ relativePath: string; fullPath: string }> = []\n\n async function scanDir(dir: string, relativePath: string = ''): Promise<void> {\n try {\n const entries = await fs.readdir(dir, { withFileTypes: true })\n \n for (const entry of entries) {\n if (entry.name.startsWith('.')) continue\n \n const fullPath = path.join(dir, entry.name)\n const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name\n\n // Skip the images folder (generated thumbnails)\n if (relPath === 'images' || relPath.startsWith('images/')) continue\n\n if (entry.isDirectory()) {\n await scanDir(fullPath, relPath)\n } else if (isMediaFile(entry.name)) {\n allFiles.push({ relativePath: relPath, fullPath })\n }\n }\n } catch {\n // Directory might not exist\n }\n }\n\n const publicDir = path.join(process.cwd(), 'public')\n await scanDir(publicDir)\n\n const total = allFiles.length\n sendEvent({ type: 'start', total })\n\n for (let i = 0; i < allFiles.length; i++) {\n let { relativePath, fullPath } = allFiles[i]\n let imageKey = '/' + relativePath\n \n sendEvent({ \n type: 'progress', \n current: i + 1, \n total, \n percent: Math.round(((i + 1) / total) * 100),\n currentFile: relativePath \n })\n\n // Check if already in meta\n if (existingKeys.has(imageKey)) {\n // File already tracked - skip\n continue\n }\n\n // Check for collision (path exists in meta but file is new)\n if (meta[imageKey]) {\n // Need to rename this file to avoid collision\n const ext = path.extname(relativePath)\n const baseName = relativePath.slice(0, -ext.length)\n let counter = 1\n let newKey = `/${baseName}-${counter}${ext}`\n \n while (meta[newKey]) {\n counter++\n newKey = `/${baseName}-${counter}${ext}`\n }\n \n // Rename the physical file\n const newRelativePath = `${baseName}-${counter}${ext}`\n const newFullPath = path.join(process.cwd(), 'public', newRelativePath)\n \n try {\n await fs.rename(fullPath, newFullPath)\n renamed.push({ from: relativePath, to: newRelativePath })\n relativePath = newRelativePath\n fullPath = newFullPath\n imageKey = newKey\n } catch (err) {\n console.error(`Failed to rename ${relativePath}:`, err)\n errors.push(`Failed to rename ${relativePath}`)\n continue\n }\n }\n\n try {\n const isImage = isImageFile(relativePath)\n \n if (isImage) {\n // Read dimensions for images\n const ext = path.extname(relativePath).toLowerCase()\n \n if (ext === '.svg') {\n // SVGs don't have pixel dimensions in the same way\n meta[imageKey] = { w: 0, h: 0 }\n } else {\n try {\n const metadata = await sharp(fullPath).metadata()\n meta[imageKey] = {\n w: metadata.width || 0,\n h: metadata.height || 0,\n }\n } catch {\n // Couldn't read dimensions\n meta[imageKey] = { w: 0, h: 0 }\n }\n }\n } else {\n // Non-image files - just add empty entry\n meta[imageKey] = {}\n }\n \n existingKeys.add(imageKey)\n added.push(imageKey)\n } catch (error) {\n console.error(`Failed to process ${relativePath}:`, error)\n errors.push(relativePath)\n }\n }\n\n await saveMeta(meta)\n\n sendEvent({ \n type: 'complete', \n added: added.length, \n renamed: renamed.length,\n errors: errors.length,\n renamedFiles: renamed,\n })\n } catch (error) {\n console.error('Scan failed:', error)\n sendEvent({ type: 'error', message: 'Scan failed' })\n } finally {\n controller.close()\n }\n }\n })\n\n return new Response(stream, {\n headers: {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache',\n 'Connection': 'keep-alive',\n },\n })\n}\n"]}
|