@ibanzajoe/uploader 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs ADDED
@@ -0,0 +1,1494 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var src_exports = {};
22
+ __export(src_exports, {
23
+ DropPane: () => DropPane,
24
+ ImageEditor: () => ImageEditor,
25
+ PickerOverlay: () => PickerOverlay,
26
+ UploaderClient: () => UploaderClient,
27
+ crop: () => crop,
28
+ editImage: () => editImage,
29
+ flip: () => flip,
30
+ flop: () => flop,
31
+ output: () => output,
32
+ quality: () => quality,
33
+ resize: () => resize,
34
+ rotate: () => rotate,
35
+ transformUrl: () => transformUrl,
36
+ usePicker: () => usePicker
37
+ });
38
+ module.exports = __toCommonJS(src_exports);
39
+
40
+ // src/core/errors.ts
41
+ var UploaderError = class extends Error {
42
+ code;
43
+ /** HTTP status code when available (undefined for ABORTED / NETWORK_ERROR). */
44
+ statusCode;
45
+ constructor(code, message, statusCode) {
46
+ super(message);
47
+ this.name = "UploaderError";
48
+ this.code = code;
49
+ this.statusCode = statusCode;
50
+ }
51
+ };
52
+
53
+ // src/core/chunk.ts
54
+ var MULTIPART_THRESHOLD = 5 * 1024 * 1024;
55
+ var DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024;
56
+ function planChunks(file, chunkSize = DEFAULT_CHUNK_SIZE) {
57
+ if (file.size <= MULTIPART_THRESHOLD) {
58
+ return { mode: "single" };
59
+ }
60
+ const parts = [];
61
+ let offset = 0;
62
+ while (offset < file.size) {
63
+ parts.push(file.slice(offset, offset + chunkSize));
64
+ offset += chunkSize;
65
+ }
66
+ return { mode: "multipart", parts, partSize: chunkSize };
67
+ }
68
+
69
+ // src/core/client.ts
70
+ var MAX_RETRIES = 3;
71
+ var RETRY_BASE_MS = 200;
72
+ function sleep(ms, signal) {
73
+ return new Promise((resolve, reject) => {
74
+ if (signal?.aborted) {
75
+ reject(new UploaderError("ABORTED", "Upload aborted"));
76
+ return;
77
+ }
78
+ const timer = setTimeout(resolve, ms);
79
+ signal?.addEventListener("abort", () => {
80
+ clearTimeout(timer);
81
+ reject(new UploaderError("ABORTED", "Upload aborted"));
82
+ }, { once: true });
83
+ });
84
+ }
85
+ function checkAbort(signal) {
86
+ if (signal?.aborted) {
87
+ throw new UploaderError("ABORTED", "Upload aborted");
88
+ }
89
+ }
90
+ async function fetchWithRetry(url, init, signal, maxRetries = MAX_RETRIES) {
91
+ let lastErr;
92
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
93
+ checkAbort(signal);
94
+ try {
95
+ const res = await fetch(url, { ...init, signal });
96
+ if (res.status >= 400 && res.status < 500) {
97
+ const body = await res.text().catch(() => "");
98
+ throw new UploaderError(
99
+ "CLIENT_ERROR",
100
+ `HTTP ${res.status}: ${body}`,
101
+ res.status
102
+ );
103
+ }
104
+ if (res.status >= 500) {
105
+ lastErr = new UploaderError(
106
+ "SERVER_ERROR",
107
+ `HTTP ${res.status}`,
108
+ res.status
109
+ );
110
+ if (attempt < maxRetries - 1) {
111
+ await sleep(RETRY_BASE_MS * 2 ** attempt, signal);
112
+ }
113
+ continue;
114
+ }
115
+ return res;
116
+ } catch (err) {
117
+ if (err instanceof UploaderError) {
118
+ if (err.code === "CLIENT_ERROR" || err.code === "ABORTED") throw err;
119
+ lastErr = err;
120
+ } else {
121
+ lastErr = new UploaderError(
122
+ "NETWORK_ERROR",
123
+ err instanceof Error ? err.message : String(err)
124
+ );
125
+ }
126
+ if (attempt < maxRetries - 1) {
127
+ await sleep(RETRY_BASE_MS * 2 ** attempt, signal);
128
+ }
129
+ }
130
+ }
131
+ throw lastErr;
132
+ }
133
+ var UploaderClient = class {
134
+ apikey;
135
+ apiUrl;
136
+ security;
137
+ constructor(options) {
138
+ this.apikey = options.apikey;
139
+ this.apiUrl = (options.apiUrl ?? "https://api.uploaderhq.io").replace(/\/$/, "");
140
+ this.security = options.security;
141
+ }
142
+ // ─── Auth headers ──────────────────────────────────────────────────────────
143
+ /**
144
+ * Build the auth headers shared by all requests.
145
+ * Attaches the API key and, when present, the signed policy pair.
146
+ */
147
+ #authHeaders() {
148
+ const headers = {
149
+ "X-Uploader-Key": this.apikey
150
+ };
151
+ if (this.security) {
152
+ headers["X-Uploader-Policy"] = this.security.policy;
153
+ headers["X-Uploader-Signature"] = this.security.signature;
154
+ }
155
+ return headers;
156
+ }
157
+ // ─── Single-shot upload ────────────────────────────────────────────────────
158
+ /**
159
+ * POST /api/store — multipart/form-data for files ≤ MULTIPART_THRESHOLD.
160
+ */
161
+ async #uploadSingleShot(file, opts) {
162
+ const { onProgress, filename, signal } = opts;
163
+ onProgress?.(0);
164
+ checkAbort(signal);
165
+ const form = new FormData();
166
+ form.append("file", file, filename ?? (file instanceof File ? file.name : "upload"));
167
+ if (filename) form.append("filename", filename);
168
+ const res = await fetchWithRetry(
169
+ `${this.apiUrl}/api/store`,
170
+ {
171
+ method: "POST",
172
+ headers: this.#authHeaders(),
173
+ body: form
174
+ },
175
+ signal
176
+ );
177
+ const body = await res.json();
178
+ onProgress?.(100);
179
+ return this.#parseFileResult(body);
180
+ }
181
+ // ─── Multipart upload ──────────────────────────────────────────────────────
182
+ /**
183
+ * start → parts (parallel with progress) → complete for files > MULTIPART_THRESHOLD.
184
+ */
185
+ async #uploadMultipart(file, parts, opts) {
186
+ const { onProgress, filename, signal } = opts;
187
+ const name = filename ?? (file instanceof File ? file.name : "upload");
188
+ const mime = file instanceof File ? file.type : "application/octet-stream";
189
+ onProgress?.(0);
190
+ checkAbort(signal);
191
+ const startRes = await fetchWithRetry(
192
+ `${this.apiUrl}/api/upload/start`,
193
+ {
194
+ method: "POST",
195
+ headers: { ...this.#authHeaders(), "Content-Type": "application/json" },
196
+ body: JSON.stringify({ filename: name, mimetype: mime, size: file.size })
197
+ },
198
+ signal
199
+ );
200
+ const { uploadId } = await startRes.json();
201
+ const etags = [];
202
+ let uploadedBytes = 0;
203
+ for (let i = 0; i < parts.length; i++) {
204
+ checkAbort(signal);
205
+ const partBlob = parts[i];
206
+ const partNumber = i + 1;
207
+ const partForm = new FormData();
208
+ partForm.append("uploadId", uploadId);
209
+ partForm.append("partNumber", String(partNumber));
210
+ partForm.append("part", partBlob);
211
+ const partRes = await fetchWithRetry(
212
+ `${this.apiUrl}/api/upload/part`,
213
+ {
214
+ method: "POST",
215
+ headers: this.#authHeaders(),
216
+ body: partForm
217
+ },
218
+ signal
219
+ );
220
+ const { etag } = await partRes.json();
221
+ etags.push({ partNumber, etag });
222
+ uploadedBytes += partBlob.size;
223
+ onProgress?.(Math.round(uploadedBytes / file.size * 95));
224
+ }
225
+ checkAbort(signal);
226
+ const completeRes = await fetchWithRetry(
227
+ `${this.apiUrl}/api/upload/complete`,
228
+ {
229
+ method: "POST",
230
+ headers: { ...this.#authHeaders(), "Content-Type": "application/json" },
231
+ body: JSON.stringify({ uploadId, parts: etags })
232
+ },
233
+ signal
234
+ );
235
+ const body = await completeRes.json();
236
+ onProgress?.(100);
237
+ return this.#parseFileResult(body);
238
+ }
239
+ // ─── Response parser ───────────────────────────────────────────────────────
240
+ #parseFileResult(body) {
241
+ if (typeof body !== "object" || body === null || typeof body["handle"] !== "string" || typeof body["url"] !== "string") {
242
+ throw new UploaderError("INVALID_RESPONSE", "Unexpected response shape from upload API");
243
+ }
244
+ return body;
245
+ }
246
+ // ─── Public API ───────────────────────────────────────────────────────────
247
+ /**
248
+ * Upload a single file.
249
+ *
250
+ * Automatically selects single-shot vs multipart upload based on file size.
251
+ * Emits progress via `opts.onProgress` (0–100). Respects `opts.signal` for
252
+ * cancellation. Retries network errors and 5xx responses up to 3 times with
253
+ * exponential backoff; 4xx errors are surfaced immediately.
254
+ *
255
+ * @throws {UploaderError} with code ABORTED | NETWORK_ERROR | SERVER_ERROR |
256
+ * CLIENT_ERROR | INVALID_RESPONSE
257
+ */
258
+ async upload(file, opts = {}) {
259
+ const plan = planChunks(file, opts.chunkSize);
260
+ if (plan.mode === "single") {
261
+ return this.#uploadSingleShot(file, opts);
262
+ }
263
+ return this.#uploadMultipart(file, plan.parts, opts);
264
+ }
265
+ /**
266
+ * Upload multiple files with concurrency limiting.
267
+ *
268
+ * Resolves once all uploads settle (fulfilled or rejected). The returned
269
+ * array preserves input order. `opts.concurrency` caps simultaneous
270
+ * in-flight uploads (default 3). Each file shares the same opts
271
+ * (including onProgress — the callback fires per-file, not aggregate).
272
+ *
273
+ * @returns Array of PromiseSettledResult in input order.
274
+ */
275
+ async uploadAll(files, opts = {}) {
276
+ const concurrency = opts.concurrency ?? 3;
277
+ const results = new Array(files.length);
278
+ let index = 0;
279
+ async function worker(client) {
280
+ while (index < files.length) {
281
+ const i = index++;
282
+ const file = files[i];
283
+ try {
284
+ results[i] = { status: "fulfilled", value: await client.upload(file, opts) };
285
+ } catch (err) {
286
+ results[i] = { status: "rejected", reason: err };
287
+ }
288
+ }
289
+ }
290
+ const workers = Array.from(
291
+ { length: Math.min(concurrency, files.length) },
292
+ () => worker(this)
293
+ );
294
+ await Promise.all(workers);
295
+ return results;
296
+ }
297
+ };
298
+
299
+ // src/core/transform.ts
300
+ var resize = (params) => ({ name: "resize", params });
301
+ var crop = (rect) => ({
302
+ name: "crop",
303
+ params: { dim: `${rect.x},${rect.y},${rect.w},${rect.h}`, ...rect }
304
+ });
305
+ var rotate = (params) => ({ name: "rotate", params });
306
+ var flip = () => ({ name: "flip", params: {} });
307
+ var flop = () => ({ name: "flop", params: {} });
308
+ var quality = (params) => ({ name: "quality", params });
309
+ var output = (params) => ({ name: "output", params });
310
+ function serializeOp(op) {
311
+ switch (op.name) {
312
+ case "resize": {
313
+ const parts = [];
314
+ if (op.params.w !== void 0) parts.push(`w:${op.params.w}`);
315
+ if (op.params.h !== void 0) parts.push(`h:${op.params.h}`);
316
+ if (op.params.fit !== void 0) parts.push(`fit:${op.params.fit}`);
317
+ return `resize=${parts.join(",")}`;
318
+ }
319
+ case "crop":
320
+ return `crop=dim:${op.params.dim}`;
321
+ case "rotate":
322
+ return `rotate=deg:${op.params.deg}`;
323
+ case "flip":
324
+ return "flip";
325
+ case "flop":
326
+ return "flop";
327
+ case "quality":
328
+ return `quality=n:${op.params.n}`;
329
+ case "output":
330
+ return `output=format:${op.params.format}`;
331
+ }
332
+ }
333
+ function transformUrl({ handle, ops, apiUrl = "" }) {
334
+ const base = apiUrl.replace(/\/$/, "");
335
+ const chain = ops.map(serializeOp).join("/");
336
+ return chain ? `${base}/${chain}/${handle}` : `${base}/${handle}`;
337
+ }
338
+
339
+ // src/react/PickerOverlay.tsx
340
+ var import_react3 = require("react");
341
+
342
+ // src/react/usePicker.ts
343
+ var import_react = require("react");
344
+ var _idCounter = 0;
345
+ function nextId() {
346
+ return `pf-${++_idCounter}`;
347
+ }
348
+ function usePicker(opts) {
349
+ const [files, setFiles] = (0, import_react.useState)([]);
350
+ const clientRef = (0, import_react.useRef)(null);
351
+ if (!clientRef.current || clientRef.current.apikey !== opts.apikey || clientRef.current.apiUrl !== (opts.apiUrl ?? "https://api.uploaderhq.io")) {
352
+ clientRef.current = new UploaderClient({
353
+ apikey: opts.apikey,
354
+ apiUrl: opts.apiUrl,
355
+ security: opts.security
356
+ });
357
+ }
358
+ const addFiles = (0, import_react.useCallback)(
359
+ (incoming) => {
360
+ const list = Array.from(incoming);
361
+ const { maxFiles, maxSize } = opts.pickerOptions ?? {};
362
+ setFiles((prev) => {
363
+ const remaining = maxFiles != null ? maxFiles - prev.length : Infinity;
364
+ const toAdd = list.slice(0, remaining).filter((f) => {
365
+ if (maxSize != null && f.size > maxSize) return false;
366
+ return true;
367
+ });
368
+ const newEntries = toAdd.map((f) => ({
369
+ file: f,
370
+ id: nextId(),
371
+ state: "pending",
372
+ progress: null,
373
+ previewUrl: f.type.startsWith("image/") ? URL.createObjectURL(f) : void 0
374
+ }));
375
+ return [...prev, ...newEntries];
376
+ });
377
+ },
378
+ [opts.pickerOptions]
379
+ );
380
+ const removeFile = (0, import_react.useCallback)((id) => {
381
+ setFiles((prev) => {
382
+ const entry = prev.find((f) => f.id === id);
383
+ if (entry?.previewUrl) URL.revokeObjectURL(entry.previewUrl);
384
+ return prev.filter((f) => f.id !== id);
385
+ });
386
+ }, []);
387
+ const editFile = (0, import_react.useCallback)((id, newFile) => {
388
+ setFiles(
389
+ (prev) => prev.map((f) => {
390
+ if (f.id !== id) return f;
391
+ if (f.previewUrl) URL.revokeObjectURL(f.previewUrl);
392
+ return {
393
+ ...f,
394
+ file: newFile,
395
+ state: "pending",
396
+ progress: null,
397
+ error: void 0,
398
+ result: void 0,
399
+ previewUrl: newFile.type.startsWith("image/") ? URL.createObjectURL(newFile) : void 0
400
+ };
401
+ })
402
+ );
403
+ }, []);
404
+ const retryFile = (0, import_react.useCallback)((id) => {
405
+ setFiles(
406
+ (prev) => prev.map(
407
+ (f) => f.id === id ? { ...f, state: "pending", progress: null, error: void 0 } : f
408
+ )
409
+ );
410
+ }, []);
411
+ const upload = (0, import_react.useCallback)(async () => {
412
+ const client = clientRef.current;
413
+ const snapshot = files.filter((f) => f.state === "pending");
414
+ const uploaded = [];
415
+ const failed = [];
416
+ for (const entry of snapshot) {
417
+ setFiles(
418
+ (prev) => prev.map((f) => f.id === entry.id ? { ...f, state: "uploading", progress: 0 } : f)
419
+ );
420
+ try {
421
+ const result = await client.upload(entry.file, {
422
+ onProgress: (pct) => {
423
+ setFiles(
424
+ (prev) => prev.map((f) => f.id === entry.id ? { ...f, progress: pct } : f)
425
+ );
426
+ }
427
+ });
428
+ setFiles(
429
+ (prev) => prev.map(
430
+ (f) => f.id === entry.id ? { ...f, state: "done", progress: 100, result } : f
431
+ )
432
+ );
433
+ opts.onFileUploadFinished?.(result);
434
+ uploaded.push(result);
435
+ } catch (err) {
436
+ const error = err instanceof Error ? err : new Error(String(err));
437
+ setFiles(
438
+ (prev) => prev.map(
439
+ (f) => f.id === entry.id ? { ...f, state: "error", progress: null, error: error.message } : f
440
+ )
441
+ );
442
+ opts.onFileUploadFailed?.(entry.file, error);
443
+ failed.push({
444
+ handle: "",
445
+ url: "",
446
+ filename: entry.file.name,
447
+ mimetype: entry.file.type,
448
+ size: entry.file.size,
449
+ status: "Stored"
450
+ });
451
+ }
452
+ }
453
+ if (snapshot.length > 0) {
454
+ opts.onUploadDone?.({ filesUploaded: uploaded, filesFailed: failed });
455
+ }
456
+ }, [files, opts]);
457
+ const activeFiles = files.filter((f) => f.state === "uploading" || f.state === "done");
458
+ const progress = activeFiles.length === 0 ? 0 : Math.round(
459
+ activeFiles.reduce((sum, f) => sum + (f.progress ?? 0), 0) / activeFiles.length
460
+ );
461
+ const isUploading = files.some((f) => f.state === "uploading");
462
+ const isDone = files.length > 0 && files.every((f) => f.state === "done" || f.state === "error");
463
+ return { files, addFiles, removeFile, editFile, retryFile, upload, progress, isUploading, isDone };
464
+ }
465
+
466
+ // src/react/ImageEditor.tsx
467
+ var import_react2 = require("react");
468
+
469
+ // src/react/editImage.ts
470
+ var ENCODABLE = /* @__PURE__ */ new Set(["image/jpeg", "image/png", "image/webp"]);
471
+ function mimeToExtension(mime) {
472
+ switch (mime) {
473
+ case "image/jpeg":
474
+ return "jpg";
475
+ case "image/png":
476
+ return "png";
477
+ case "image/webp":
478
+ return "webp";
479
+ default:
480
+ return "png";
481
+ }
482
+ }
483
+ function pickOutputType(sourceType, override) {
484
+ if (override) return override;
485
+ return ENCODABLE.has(sourceType) ? sourceType : "image/png";
486
+ }
487
+ function rotatedBoundingBox(width, height, deg) {
488
+ const rad = deg * Math.PI / 180;
489
+ return {
490
+ width: Math.abs(Math.cos(rad) * width) + Math.abs(Math.sin(rad) * height),
491
+ height: Math.abs(Math.sin(rad) * width) + Math.abs(Math.cos(rad) * height)
492
+ };
493
+ }
494
+ function fitScale(width, height, maxDimension) {
495
+ if (!maxDimension) return 1;
496
+ const longest = Math.max(width, height);
497
+ return longest > maxDimension ? maxDimension / longest : 1;
498
+ }
499
+ function withExtension(name, ext) {
500
+ const dot = name.lastIndexOf(".");
501
+ const base = dot > 0 ? name.slice(0, dot) : name;
502
+ return `${base}.${ext}`;
503
+ }
504
+ function loadImage(url) {
505
+ return new Promise((resolve, reject) => {
506
+ const img = new Image();
507
+ img.onload = () => resolve(img);
508
+ img.onerror = () => reject(new Error("Failed to load image for editing"));
509
+ img.src = url;
510
+ });
511
+ }
512
+ async function editImage(file, opts = {}) {
513
+ const {
514
+ rotation = 0,
515
+ flip: flip2 = { horizontal: false, vertical: false },
516
+ crop: crop2,
517
+ maxDimension
518
+ } = opts;
519
+ const url = URL.createObjectURL(file);
520
+ try {
521
+ const image = await loadImage(url);
522
+ const iw = image.naturalWidth || image.width;
523
+ const ih = image.naturalHeight || image.height;
524
+ const box = rotatedBoundingBox(iw, ih, rotation);
525
+ const boxCanvas = document.createElement("canvas");
526
+ boxCanvas.width = Math.max(1, Math.round(box.width));
527
+ boxCanvas.height = Math.max(1, Math.round(box.height));
528
+ const boxCtx = boxCanvas.getContext("2d");
529
+ if (!boxCtx) throw new Error("Canvas 2D context unavailable");
530
+ boxCtx.translate(boxCanvas.width / 2, boxCanvas.height / 2);
531
+ boxCtx.rotate(rotation * Math.PI / 180);
532
+ boxCtx.scale(flip2.horizontal ? -1 : 1, flip2.vertical ? -1 : 1);
533
+ boxCtx.drawImage(image, -iw / 2, -ih / 2);
534
+ const region = crop2 ?? {
535
+ x: 0,
536
+ y: 0,
537
+ width: boxCanvas.width,
538
+ height: boxCanvas.height
539
+ };
540
+ const scale = fitScale(region.width, region.height, maxDimension);
541
+ const outW = Math.max(1, Math.round(region.width * scale));
542
+ const outH = Math.max(1, Math.round(region.height * scale));
543
+ const outCanvas = document.createElement("canvas");
544
+ outCanvas.width = outW;
545
+ outCanvas.height = outH;
546
+ const outCtx = outCanvas.getContext("2d");
547
+ if (!outCtx) throw new Error("Canvas 2D context unavailable");
548
+ if (opts.circle) {
549
+ outCtx.beginPath();
550
+ outCtx.ellipse(outW / 2, outH / 2, outW / 2, outH / 2, 0, 0, Math.PI * 2);
551
+ outCtx.clip();
552
+ }
553
+ outCtx.drawImage(boxCanvas, region.x, region.y, region.width, region.height, 0, 0, outW, outH);
554
+ const outputType = opts.circle ? "image/png" : pickOutputType(file.type, opts.outputType);
555
+ const quality2 = opts.quality ?? 0.92;
556
+ const blob = await new Promise((resolve, reject) => {
557
+ outCanvas.toBlob(
558
+ (b) => b ? resolve(b) : reject(new Error("Canvas toBlob returned null")),
559
+ outputType,
560
+ quality2
561
+ );
562
+ });
563
+ const name = opts.fileName ?? withExtension(file.name, mimeToExtension(outputType));
564
+ return new File([blob], name, { type: outputType, lastModified: Date.now() });
565
+ } finally {
566
+ URL.revokeObjectURL(url);
567
+ }
568
+ }
569
+
570
+ // src/react/ImageEditor.tsx
571
+ var import_jsx_runtime = require("react/jsx-runtime");
572
+ var clampNum = (n, min, max) => Math.min(max, Math.max(min, n));
573
+ var MIN_CROP = 48;
574
+ var MAX_ZOOM = 8;
575
+ var ASPECTS = [
576
+ { label: "Free", value: void 0 },
577
+ { label: "1:1", value: 1 },
578
+ { label: "4:3", value: 4 / 3 },
579
+ { label: "16:9", value: 16 / 9 },
580
+ { label: "3:2", value: 3 / 2 }
581
+ ];
582
+ var HANDLES = [
583
+ { id: "nw", pos: { left: -7, top: -7, cursor: "nwse-resize" } },
584
+ { id: "ne", pos: { right: -7, top: -7, cursor: "nesw-resize" } },
585
+ { id: "sw", pos: { left: -7, bottom: -7, cursor: "nesw-resize" } },
586
+ { id: "se", pos: { right: -7, bottom: -7, cursor: "nwse-resize" } }
587
+ ];
588
+ function ImageEditor({
589
+ file,
590
+ onApply,
591
+ onCancel,
592
+ outputType,
593
+ quality: quality2,
594
+ maxDimensionDefault = 1920,
595
+ className,
596
+ style
597
+ }) {
598
+ const [rotation, setRotation] = (0, import_react2.useState)(0);
599
+ const [flip2, setFlip] = (0, import_react2.useState)({ horizontal: false, vertical: false });
600
+ const [aspect, setAspect] = (0, import_react2.useState)(void 0);
601
+ const [cropShape, setCropShape] = (0, import_react2.useState)("rect");
602
+ const [resizeEnabled, setResizeEnabled] = (0, import_react2.useState)(false);
603
+ const [maxDimension, setMaxDimension] = (0, import_react2.useState)(maxDimensionDefault);
604
+ const [busy, setBusy] = (0, import_react2.useState)(false);
605
+ const [error, setError] = (0, import_react2.useState)(null);
606
+ const [workingFile, setWorkingFile] = (0, import_react2.useState)(file);
607
+ (0, import_react2.useEffect)(() => {
608
+ let cancelled = false;
609
+ if (rotation === 0 && !flip2.horizontal && !flip2.vertical) {
610
+ setWorkingFile(file);
611
+ return;
612
+ }
613
+ editImage(file, { rotation, flip: flip2 }).then((f) => {
614
+ if (!cancelled) setWorkingFile(f);
615
+ }).catch(() => {
616
+ if (!cancelled) setError("Failed to render the rotated preview");
617
+ });
618
+ return () => {
619
+ cancelled = true;
620
+ };
621
+ }, [file, rotation, flip2]);
622
+ const [workingUrl, setWorkingUrl] = (0, import_react2.useState)("");
623
+ (0, import_react2.useEffect)(() => {
624
+ const url = URL.createObjectURL(workingFile);
625
+ setWorkingUrl(url);
626
+ return () => URL.revokeObjectURL(url);
627
+ }, [workingFile]);
628
+ const viewportRef = (0, import_react2.useRef)(null);
629
+ const [vp, setVp] = (0, import_react2.useState)({ w: 0, h: 0 });
630
+ const [nat, setNat] = (0, import_react2.useState)({ w: 0, h: 0 });
631
+ const [view, setView] = (0, import_react2.useState)({ scale: 1, x: 0, y: 0 });
632
+ const [crop2, setCrop] = (0, import_react2.useState)({ w: 0, h: 0 });
633
+ const ready = nat.w > 0 && vp.w > 0;
634
+ (0, import_react2.useEffect)(() => {
635
+ const el = viewportRef.current;
636
+ if (!el || typeof ResizeObserver === "undefined") return;
637
+ const measure = () => {
638
+ const r = el.getBoundingClientRect();
639
+ setVp({ w: r.width, h: r.height });
640
+ };
641
+ measure();
642
+ const ro = new ResizeObserver(measure);
643
+ ro.observe(el);
644
+ return () => ro.disconnect();
645
+ }, []);
646
+ const normalize = (0, import_react2.useCallback)(
647
+ (scaleRaw, xRaw, yRaw, cw, ch) => {
648
+ if (!nat.w || !vp.w) return { scale: scaleRaw, x: xRaw, y: yRaw };
649
+ const fitS = Math.min(vp.w / nat.w, vp.h / nat.h);
650
+ const coverMin = Math.max(cw / nat.w, ch / nat.h);
651
+ const scale = clampNum(scaleRaw, coverMin, Math.max(fitS, coverMin) * MAX_ZOOM);
652
+ const cbX2 = (vp.w - cw) / 2;
653
+ const cbY2 = (vp.h - ch) / 2;
654
+ const x = clampNum(xRaw, cbX2 + cw - scale * nat.w, cbX2);
655
+ const y = clampNum(yRaw, cbY2 + ch - scale * nat.h, cbY2);
656
+ return { scale, x, y };
657
+ },
658
+ [nat, vp]
659
+ );
660
+ (0, import_react2.useEffect)(() => {
661
+ if (!nat.w || !vp.w) return;
662
+ const fitS = Math.min(vp.w / nat.w, vp.h / nat.h);
663
+ const baseline = Math.min(vp.w, vp.h) * 0.7;
664
+ let cw = baseline;
665
+ let ch = baseline;
666
+ if (cropShape === "round") {
667
+ cw = ch = baseline;
668
+ } else if (aspect) {
669
+ if (aspect >= 1) ch = baseline / aspect;
670
+ else cw = baseline * aspect;
671
+ }
672
+ cw = clampNum(cw, MIN_CROP, vp.w);
673
+ ch = clampNum(ch, MIN_CROP, vp.h);
674
+ setCrop({ w: cw, h: ch });
675
+ setView(normalize(fitS, (vp.w - fitS * nat.w) / 2, (vp.h - fitS * nat.h) / 2, cw, ch));
676
+ }, [nat, vp]);
677
+ (0, import_react2.useEffect)(() => {
678
+ if (!nat.w || !vp.w) return;
679
+ let cw = crop2.w;
680
+ let ch = crop2.h;
681
+ if (cropShape === "round") {
682
+ cw = ch = Math.min(cw, ch);
683
+ } else if (aspect) {
684
+ ch = cw / aspect;
685
+ if (ch > vp.h) {
686
+ ch = vp.h;
687
+ cw = ch * aspect;
688
+ }
689
+ }
690
+ cw = clampNum(cw, MIN_CROP, vp.w);
691
+ ch = clampNum(ch, MIN_CROP, vp.h);
692
+ setCrop({ w: cw, h: ch });
693
+ setView((v) => normalize(v.scale, v.x, v.y, cw, ch));
694
+ }, [aspect, cropShape]);
695
+ const onImageLoad = (0, import_react2.useCallback)((e) => {
696
+ setNat({ w: e.currentTarget.naturalWidth, h: e.currentTarget.naturalHeight });
697
+ }, []);
698
+ (0, import_react2.useEffect)(() => {
699
+ const el = viewportRef.current;
700
+ if (!el) return;
701
+ const onWheel = (e) => {
702
+ e.preventDefault();
703
+ const rect = el.getBoundingClientRect();
704
+ const fx = e.clientX - rect.left;
705
+ const fy = e.clientY - rect.top;
706
+ const factor = Math.exp(-e.deltaY * 15e-4);
707
+ setView((v) => {
708
+ const next = normalize(v.scale * factor, v.x, v.y, crop2.w, crop2.h);
709
+ const ratio = next.scale / v.scale;
710
+ return normalize(next.scale, fx - ratio * (fx - v.x), fy - ratio * (fy - v.y), crop2.w, crop2.h);
711
+ });
712
+ };
713
+ el.addEventListener("wheel", onWheel, { passive: false });
714
+ return () => el.removeEventListener("wheel", onWheel);
715
+ }, [normalize, crop2.w, crop2.h]);
716
+ const dragRef = (0, import_react2.useRef)(null);
717
+ const onPointerDown = (0, import_react2.useCallback)(
718
+ (e) => {
719
+ if (!ready) return;
720
+ const handle = e.target.dataset.handle;
721
+ e.currentTarget.setPointerCapture(e.pointerId);
722
+ if (handle) {
723
+ dragRef.current = { mode: "resize", corner: handle };
724
+ } else {
725
+ dragRef.current = { mode: "pan", downX: e.clientX, downY: e.clientY, startX: view.x, startY: view.y };
726
+ }
727
+ },
728
+ [ready, view.x, view.y]
729
+ );
730
+ const onPointerMove = (0, import_react2.useCallback)(
731
+ (e) => {
732
+ const d = dragRef.current;
733
+ if (!d) return;
734
+ const rect = e.currentTarget.getBoundingClientRect();
735
+ if (d.mode === "pan") {
736
+ setView((v) => normalize(v.scale, d.startX + (e.clientX - d.downX), d.startY + (e.clientY - d.downY), crop2.w, crop2.h));
737
+ } else {
738
+ const hx = Math.abs(e.clientX - rect.left - rect.width / 2);
739
+ const hy = Math.abs(e.clientY - rect.top - rect.height / 2);
740
+ let cw;
741
+ let ch;
742
+ if (cropShape === "round") {
743
+ cw = ch = 2 * Math.max(hx, hy);
744
+ } else if (aspect) {
745
+ cw = Math.max(2 * hx, 2 * hy * aspect);
746
+ ch = cw / aspect;
747
+ } else {
748
+ cw = 2 * hx;
749
+ ch = 2 * hy;
750
+ }
751
+ cw = clampNum(cw, MIN_CROP, rect.width);
752
+ ch = clampNum(ch, MIN_CROP, rect.height);
753
+ if (cropShape === "round") cw = ch = Math.min(cw, ch);
754
+ setCrop({ w: cw, h: ch });
755
+ setView((v) => normalize(v.scale, v.x, v.y, cw, ch));
756
+ }
757
+ },
758
+ [normalize, crop2.w, crop2.h, cropShape, aspect]
759
+ );
760
+ const onPointerUp = (0, import_react2.useCallback)((e) => {
761
+ dragRef.current = null;
762
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
763
+ }, []);
764
+ const handleApply = (0, import_react2.useCallback)(async () => {
765
+ if (!ready) {
766
+ onApply(workingFile);
767
+ return;
768
+ }
769
+ setBusy(true);
770
+ setError(null);
771
+ try {
772
+ const cbX2 = (vp.w - crop2.w) / 2;
773
+ const cbY2 = (vp.h - crop2.h) / 2;
774
+ const region = {
775
+ x: (cbX2 - view.x) / view.scale,
776
+ y: (cbY2 - view.y) / view.scale,
777
+ width: crop2.w / view.scale,
778
+ height: crop2.h / view.scale
779
+ };
780
+ const edited = await editImage(workingFile, {
781
+ crop: region,
782
+ circle: cropShape === "round",
783
+ maxDimension: resizeEnabled ? maxDimension : void 0,
784
+ outputType,
785
+ quality: quality2
786
+ });
787
+ onApply(edited);
788
+ } catch (e) {
789
+ setError(e instanceof Error ? e.message : "Failed to apply edits");
790
+ } finally {
791
+ setBusy(false);
792
+ }
793
+ }, [ready, vp, crop2, view, workingFile, cropShape, resizeEnabled, maxDimension, outputType, quality2, onApply]);
794
+ const cbX = (vp.w - crop2.w) / 2;
795
+ const cbY = (vp.h - crop2.h) / 2;
796
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
797
+ "div",
798
+ {
799
+ className: ["uploader-editor", className ?? ""].filter(Boolean).join(" "),
800
+ style,
801
+ "data-testid": "image-editor",
802
+ children: [
803
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
804
+ "div",
805
+ {
806
+ ref: viewportRef,
807
+ className: "uploader-editor-canvas",
808
+ onPointerDown,
809
+ onPointerMove,
810
+ onPointerUp,
811
+ onPointerCancel: onPointerUp,
812
+ children: [
813
+ workingUrl && /* eslint-disable-next-line @next/next/no-img-element */
814
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
815
+ "img",
816
+ {
817
+ src: workingUrl,
818
+ alt: "Edit preview",
819
+ onLoad: onImageLoad,
820
+ draggable: false,
821
+ className: "uploader-editor-image",
822
+ style: {
823
+ width: nat.w || void 0,
824
+ height: nat.h || void 0,
825
+ transform: `translate(${view.x}px, ${view.y}px) scale(${view.scale})`,
826
+ opacity: ready ? 1 : 0
827
+ }
828
+ }
829
+ ),
830
+ ready && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
831
+ "div",
832
+ {
833
+ className: `uploader-editor-cropbox${cropShape === "round" ? " uploader-editor-cropbox-round" : ""}`,
834
+ style: { left: cbX, top: cbY, width: crop2.w, height: crop2.h },
835
+ children: HANDLES.map((h) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { "data-handle": h.id, className: "uploader-editor-handle", style: h.pos }, h.id))
836
+ }
837
+ )
838
+ ]
839
+ }
840
+ ),
841
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "uploader-editor-hint", children: "Drag the image to move it \xB7 scroll to zoom \xB7 drag a corner to resize the crop." }),
842
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "uploader-editor-controls", children: [
843
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "uploader-editor-row", children: [
844
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
845
+ "button",
846
+ {
847
+ type: "button",
848
+ className: "uploader-editor-tool",
849
+ "aria-label": "Rotate left",
850
+ onClick: () => setRotation((r) => (r - 90 + 360) % 360),
851
+ children: "\u27F2"
852
+ }
853
+ ),
854
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
855
+ "button",
856
+ {
857
+ type: "button",
858
+ className: "uploader-editor-tool",
859
+ "aria-label": "Rotate right",
860
+ onClick: () => setRotation((r) => (r + 90) % 360),
861
+ children: "\u27F3"
862
+ }
863
+ ),
864
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
865
+ "button",
866
+ {
867
+ type: "button",
868
+ className: `uploader-editor-tool${flip2.horizontal ? " uploader-editor-tool-active" : ""}`,
869
+ "aria-pressed": flip2.horizontal,
870
+ "aria-label": "Flip horizontal",
871
+ onClick: () => setFlip((f) => ({ ...f, horizontal: !f.horizontal })),
872
+ children: "\u21CB"
873
+ }
874
+ ),
875
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
876
+ "button",
877
+ {
878
+ type: "button",
879
+ className: `uploader-editor-tool${flip2.vertical ? " uploader-editor-tool-active" : ""}`,
880
+ "aria-pressed": flip2.vertical,
881
+ "aria-label": "Flip vertical",
882
+ onClick: () => setFlip((f) => ({ ...f, vertical: !f.vertical })),
883
+ children: "\u21C5"
884
+ }
885
+ )
886
+ ] }),
887
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "uploader-editor-row", children: [
888
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "uploader-editor-label", children: "Shape" }),
889
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
890
+ "button",
891
+ {
892
+ type: "button",
893
+ className: `uploader-editor-chip${cropShape === "rect" ? " uploader-editor-chip-active" : ""}`,
894
+ onClick: () => setCropShape("rect"),
895
+ children: "Rectangle"
896
+ }
897
+ ),
898
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
899
+ "button",
900
+ {
901
+ type: "button",
902
+ className: `uploader-editor-chip${cropShape === "round" ? " uploader-editor-chip-active" : ""}`,
903
+ onClick: () => {
904
+ setCropShape("round");
905
+ setAspect(1);
906
+ },
907
+ children: "Circle"
908
+ }
909
+ )
910
+ ] }),
911
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "uploader-editor-row", children: [
912
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "uploader-editor-label", children: "Ratio" }),
913
+ ASPECTS.map((a) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
914
+ "button",
915
+ {
916
+ type: "button",
917
+ disabled: cropShape === "round",
918
+ className: `uploader-editor-chip${cropShape === "rect" && aspect === a.value ? " uploader-editor-chip-active" : ""}`,
919
+ onClick: () => setAspect(a.value),
920
+ children: a.label
921
+ },
922
+ a.label
923
+ ))
924
+ ] }),
925
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "uploader-editor-row", children: [
926
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { className: "uploader-editor-label", children: [
927
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
928
+ "input",
929
+ {
930
+ type: "checkbox",
931
+ checked: resizeEnabled,
932
+ onChange: (e) => setResizeEnabled(e.target.checked)
933
+ }
934
+ ),
935
+ " ",
936
+ "Resize to"
937
+ ] }),
938
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
939
+ "input",
940
+ {
941
+ type: "number",
942
+ min: 16,
943
+ value: maxDimension,
944
+ disabled: !resizeEnabled,
945
+ "aria-label": "Maximum longest side in pixels",
946
+ className: "uploader-editor-number",
947
+ onChange: (e) => setMaxDimension(Number(e.target.value))
948
+ }
949
+ ),
950
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "uploader-editor-label", children: "px (longest side)" })
951
+ ] }),
952
+ error && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "uploader-editor-error", role: "alert", children: error })
953
+ ] }),
954
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "uploader-editor-actions", children: [
955
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "uploader-btn-cancel", onClick: onCancel, disabled: busy, children: "Cancel" }),
956
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "uploader-btn-upload", onClick: handleApply, disabled: busy, children: busy ? "Applying\u2026" : "Apply" })
957
+ ] })
958
+ ]
959
+ }
960
+ );
961
+ }
962
+
963
+ // src/react/PickerOverlay.tsx
964
+ var import_jsx_runtime2 = require("react/jsx-runtime");
965
+ function PickerOverlay({
966
+ open,
967
+ onClose,
968
+ apikey,
969
+ apiUrl,
970
+ security,
971
+ pickerOptions,
972
+ onUploadDone,
973
+ onFileUploadFinished,
974
+ onFileUploadFailed,
975
+ onCancel,
976
+ className,
977
+ style
978
+ }) {
979
+ const titleId = (0, import_react3.useId)();
980
+ const inputRef = (0, import_react3.useRef)(null);
981
+ const panelRef = (0, import_react3.useRef)(null);
982
+ const [isDragOver, setIsDragOver] = (0, import_react3.useState)(false);
983
+ const [editingId, setEditingId] = (0, import_react3.useState)(null);
984
+ const { files, addFiles, removeFile, editFile, retryFile, upload, progress, isUploading, isDone } = usePicker({
985
+ apikey,
986
+ apiUrl,
987
+ security,
988
+ pickerOptions,
989
+ onUploadDone,
990
+ onFileUploadFinished,
991
+ onFileUploadFailed
992
+ });
993
+ (0, import_react3.useEffect)(() => {
994
+ if (!open) return;
995
+ const handler = (e) => {
996
+ if (e.key === "Escape") {
997
+ onCancel?.();
998
+ onClose();
999
+ }
1000
+ };
1001
+ document.addEventListener("keydown", handler);
1002
+ return () => document.removeEventListener("keydown", handler);
1003
+ }, [open, onClose, onCancel]);
1004
+ (0, import_react3.useEffect)(() => {
1005
+ if (!open || !panelRef.current) return;
1006
+ const panel = panelRef.current;
1007
+ const focusable = panel.querySelectorAll(
1008
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
1009
+ );
1010
+ if (focusable.length === 0) return;
1011
+ const first = focusable[0];
1012
+ const last = focusable[focusable.length - 1];
1013
+ first.focus();
1014
+ const trap = (e) => {
1015
+ if (e.key !== "Tab") return;
1016
+ if (e.shiftKey) {
1017
+ if (document.activeElement === first) {
1018
+ e.preventDefault();
1019
+ last.focus();
1020
+ }
1021
+ } else {
1022
+ if (document.activeElement === last) {
1023
+ e.preventDefault();
1024
+ first.focus();
1025
+ }
1026
+ }
1027
+ };
1028
+ document.addEventListener("keydown", trap);
1029
+ return () => document.removeEventListener("keydown", trap);
1030
+ }, [open, files, editingId]);
1031
+ (0, import_react3.useEffect)(() => {
1032
+ if (!open) return;
1033
+ const prev = document.body.style.overflow;
1034
+ document.body.style.overflow = "hidden";
1035
+ return () => {
1036
+ document.body.style.overflow = prev;
1037
+ };
1038
+ }, [open]);
1039
+ const handleDragEnter = (0, import_react3.useCallback)((e) => {
1040
+ e.preventDefault();
1041
+ setIsDragOver(true);
1042
+ }, []);
1043
+ const handleDragLeave = (0, import_react3.useCallback)((e) => {
1044
+ e.preventDefault();
1045
+ setIsDragOver(false);
1046
+ }, []);
1047
+ const handleDragOver = (0, import_react3.useCallback)((e) => {
1048
+ e.preventDefault();
1049
+ }, []);
1050
+ const handleDrop = (0, import_react3.useCallback)(
1051
+ (e) => {
1052
+ e.preventDefault();
1053
+ setIsDragOver(false);
1054
+ if (e.dataTransfer.files.length > 0) {
1055
+ addFiles(e.dataTransfer.files);
1056
+ }
1057
+ },
1058
+ [addFiles]
1059
+ );
1060
+ const openFileDialog = (0, import_react3.useCallback)(() => inputRef.current?.click(), []);
1061
+ const handleInputChange = (0, import_react3.useCallback)(
1062
+ (e) => {
1063
+ if (e.target.files && e.target.files.length > 0) {
1064
+ addFiles(e.target.files);
1065
+ e.target.value = "";
1066
+ }
1067
+ },
1068
+ [addFiles]
1069
+ );
1070
+ const handleClose = (0, import_react3.useCallback)(() => {
1071
+ onCancel?.();
1072
+ onClose();
1073
+ }, [onCancel, onClose]);
1074
+ const hasPending = files.some((f) => f.state === "pending");
1075
+ const accept = pickerOptions?.accept?.join(",");
1076
+ const editingEntry = files.find((f) => f.id === editingId) ?? null;
1077
+ if (!open) return null;
1078
+ return (
1079
+ /* Backdrop — clicking outside the panel dismisses the modal */
1080
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1081
+ "div",
1082
+ {
1083
+ className: "uploader-backdrop",
1084
+ "data-testid": "picker-backdrop",
1085
+ onClick: handleClose,
1086
+ children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
1087
+ "div",
1088
+ {
1089
+ ref: panelRef,
1090
+ role: "dialog",
1091
+ "aria-modal": "true",
1092
+ "aria-labelledby": titleId,
1093
+ className: ["uploader-picker-panel", className ?? ""].filter(Boolean).join(" "),
1094
+ style,
1095
+ "data-testid": "picker-panel",
1096
+ onClick: (e) => e.stopPropagation(),
1097
+ children: [
1098
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "uploader-picker-header", children: [
1099
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("h2", { id: titleId, className: "uploader-picker-title", children: editingEntry ? "Edit Image" : "Upload Files" }),
1100
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1101
+ "button",
1102
+ {
1103
+ type: "button",
1104
+ className: "uploader-btn-close",
1105
+ "aria-label": "Close picker",
1106
+ onClick: handleClose,
1107
+ children: "\xD7"
1108
+ }
1109
+ )
1110
+ ] }),
1111
+ editingEntry ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1112
+ ImageEditor,
1113
+ {
1114
+ file: editingEntry.file,
1115
+ onCancel: () => setEditingId(null),
1116
+ onApply: (edited) => {
1117
+ editFile(editingEntry.id, edited);
1118
+ setEditingId(null);
1119
+ }
1120
+ }
1121
+ ) : /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
1122
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
1123
+ "div",
1124
+ {
1125
+ className: [
1126
+ "uploader-dropzone",
1127
+ isDragOver ? "uploader-drag-over" : ""
1128
+ ].filter(Boolean).join(" "),
1129
+ role: "button",
1130
+ tabIndex: 0,
1131
+ "aria-label": "Drop files here or click to browse",
1132
+ onDragEnter: handleDragEnter,
1133
+ onDragLeave: handleDragLeave,
1134
+ onDragOver: handleDragOver,
1135
+ onDrop: handleDrop,
1136
+ onClick: openFileDialog,
1137
+ onKeyDown: (e) => {
1138
+ if (e.key === "Enter" || e.key === " ") {
1139
+ e.preventDefault();
1140
+ openFileDialog();
1141
+ }
1142
+ },
1143
+ children: [
1144
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "uploader-dropzone-icon", "aria-hidden": "true", children: "\u2191" }),
1145
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "uploader-dropzone-label", children: "Drag & drop files here" }),
1146
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1147
+ "button",
1148
+ {
1149
+ type: "button",
1150
+ className: "uploader-btn-browse",
1151
+ onClick: (e) => {
1152
+ e.stopPropagation();
1153
+ openFileDialog();
1154
+ },
1155
+ children: "Browse files"
1156
+ }
1157
+ )
1158
+ ]
1159
+ }
1160
+ ),
1161
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1162
+ "input",
1163
+ {
1164
+ ref: inputRef,
1165
+ type: "file",
1166
+ multiple: !pickerOptions?.maxFiles || pickerOptions.maxFiles > 1,
1167
+ accept,
1168
+ className: "uploader-file-input",
1169
+ "aria-hidden": "true",
1170
+ tabIndex: -1,
1171
+ onChange: handleInputChange
1172
+ }
1173
+ ),
1174
+ files.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("ul", { className: "uploader-file-list", role: "list", "aria-label": "Selected files", children: files.map((entry) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("li", { className: `uploader-file-item uploader-file-${entry.state}`, children: [
1175
+ entry.previewUrl && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1176
+ "img",
1177
+ {
1178
+ src: entry.previewUrl,
1179
+ alt: "",
1180
+ "aria-hidden": "true",
1181
+ className: "uploader-file-thumb"
1182
+ }
1183
+ ),
1184
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "uploader-file-info", children: [
1185
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "uploader-file-name", children: entry.file.name }),
1186
+ entry.state === "error" && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "uploader-file-error", role: "alert", children: entry.error ?? "Upload failed" }),
1187
+ entry.state === "done" && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "uploader-file-done", children: "Uploaded" })
1188
+ ] }),
1189
+ entry.state === "uploading" && entry.progress != null && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1190
+ "progress",
1191
+ {
1192
+ className: "uploader-file-progress",
1193
+ value: entry.progress,
1194
+ max: 100,
1195
+ "aria-label": `${entry.file.name} upload progress`
1196
+ }
1197
+ ),
1198
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "uploader-file-actions", children: [
1199
+ entry.previewUrl && (entry.state === "pending" || entry.state === "error") && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1200
+ "button",
1201
+ {
1202
+ type: "button",
1203
+ className: "uploader-btn-edit",
1204
+ "aria-label": `Edit ${entry.file.name}`,
1205
+ onClick: () => setEditingId(entry.id),
1206
+ children: "Edit"
1207
+ }
1208
+ ),
1209
+ entry.state === "error" && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1210
+ "button",
1211
+ {
1212
+ type: "button",
1213
+ className: "uploader-btn-retry",
1214
+ "aria-label": `Retry uploading ${entry.file.name}`,
1215
+ onClick: () => retryFile(entry.id),
1216
+ children: "Retry"
1217
+ }
1218
+ ),
1219
+ entry.state !== "uploading" && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1220
+ "button",
1221
+ {
1222
+ type: "button",
1223
+ className: "uploader-btn-remove",
1224
+ "aria-label": `Remove ${entry.file.name}`,
1225
+ onClick: () => removeFile(entry.id),
1226
+ children: "\xD7"
1227
+ }
1228
+ )
1229
+ ] })
1230
+ ] }, entry.id)) }),
1231
+ isUploading && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "uploader-aggregate-progress", role: "status", "aria-live": "polite", children: [
1232
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("progress", { value: progress, max: 100, "aria-label": "Overall upload progress" }),
1233
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { "aria-live": "polite", children: [
1234
+ progress,
1235
+ "%"
1236
+ ] })
1237
+ ] }),
1238
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "uploader-picker-footer", children: [
1239
+ hasPending && !isUploading && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
1240
+ "button",
1241
+ {
1242
+ type: "button",
1243
+ className: "uploader-btn-upload",
1244
+ disabled: isUploading || !hasPending,
1245
+ onClick: () => void upload(),
1246
+ children: [
1247
+ "Upload ",
1248
+ files.filter((f) => f.state === "pending").length,
1249
+ " file",
1250
+ files.filter((f) => f.state === "pending").length !== 1 ? "s" : ""
1251
+ ]
1252
+ }
1253
+ ),
1254
+ isDone && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1255
+ "button",
1256
+ {
1257
+ type: "button",
1258
+ className: "uploader-btn-done",
1259
+ onClick: onClose,
1260
+ children: "Done"
1261
+ }
1262
+ ),
1263
+ !isDone && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1264
+ "button",
1265
+ {
1266
+ type: "button",
1267
+ className: "uploader-btn-cancel",
1268
+ disabled: isUploading,
1269
+ onClick: handleClose,
1270
+ children: "Cancel"
1271
+ }
1272
+ )
1273
+ ] })
1274
+ ] })
1275
+ ]
1276
+ }
1277
+ )
1278
+ }
1279
+ )
1280
+ );
1281
+ }
1282
+
1283
+ // src/react/DropPane.tsx
1284
+ var import_react4 = require("react");
1285
+ var import_jsx_runtime3 = require("react/jsx-runtime");
1286
+ function DropPane({
1287
+ apikey,
1288
+ apiUrl,
1289
+ security,
1290
+ pickerOptions,
1291
+ onUploadDone,
1292
+ onFileUploadFinished,
1293
+ onFileUploadFailed,
1294
+ onCancel,
1295
+ className,
1296
+ style
1297
+ }) {
1298
+ const inputId = (0, import_react4.useId)();
1299
+ const inputRef = (0, import_react4.useRef)(null);
1300
+ const [isDragOver, setIsDragOver] = (0, import_react4.useState)(false);
1301
+ const { files, addFiles, removeFile, retryFile, upload, progress, isUploading, isDone } = usePicker({
1302
+ apikey,
1303
+ apiUrl,
1304
+ security,
1305
+ pickerOptions,
1306
+ onUploadDone,
1307
+ onFileUploadFinished,
1308
+ onFileUploadFailed
1309
+ });
1310
+ const handleDragEnter = (0, import_react4.useCallback)((e) => {
1311
+ e.preventDefault();
1312
+ e.stopPropagation();
1313
+ setIsDragOver(true);
1314
+ }, []);
1315
+ const handleDragLeave = (0, import_react4.useCallback)((e) => {
1316
+ e.preventDefault();
1317
+ e.stopPropagation();
1318
+ setIsDragOver(false);
1319
+ }, []);
1320
+ const handleDragOver = (0, import_react4.useCallback)((e) => {
1321
+ e.preventDefault();
1322
+ e.stopPropagation();
1323
+ }, []);
1324
+ const handleDrop = (0, import_react4.useCallback)(
1325
+ (e) => {
1326
+ e.preventDefault();
1327
+ e.stopPropagation();
1328
+ setIsDragOver(false);
1329
+ if (e.dataTransfer.files.length > 0) {
1330
+ addFiles(e.dataTransfer.files);
1331
+ }
1332
+ },
1333
+ [addFiles]
1334
+ );
1335
+ const handleInputChange = (0, import_react4.useCallback)(
1336
+ (e) => {
1337
+ if (e.target.files && e.target.files.length > 0) {
1338
+ addFiles(e.target.files);
1339
+ e.target.value = "";
1340
+ }
1341
+ },
1342
+ [addFiles]
1343
+ );
1344
+ const openFileDialog = (0, import_react4.useCallback)(() => {
1345
+ inputRef.current?.click();
1346
+ }, []);
1347
+ const hasPending = files.some((f) => f.state === "pending");
1348
+ const accept = pickerOptions?.accept?.join(",");
1349
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
1350
+ "div",
1351
+ {
1352
+ className: ["uploader-drop-pane", isDragOver ? "uploader-drag-over" : "", className ?? ""].filter(Boolean).join(" "),
1353
+ style,
1354
+ "data-testid": "drop-pane",
1355
+ children: [
1356
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
1357
+ "div",
1358
+ {
1359
+ className: "uploader-dropzone",
1360
+ role: "button",
1361
+ tabIndex: 0,
1362
+ "aria-label": "Drop files here or click to browse",
1363
+ onDragEnter: handleDragEnter,
1364
+ onDragLeave: handleDragLeave,
1365
+ onDragOver: handleDragOver,
1366
+ onDrop: handleDrop,
1367
+ onClick: openFileDialog,
1368
+ onKeyDown: (e) => {
1369
+ if (e.key === "Enter" || e.key === " ") {
1370
+ e.preventDefault();
1371
+ openFileDialog();
1372
+ }
1373
+ },
1374
+ children: [
1375
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { className: "uploader-dropzone-icon", "aria-hidden": "true", children: "\u2191" }),
1376
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("span", { className: "uploader-dropzone-label", children: [
1377
+ "Drag & drop files here or",
1378
+ " ",
1379
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("label", { htmlFor: inputId, className: "uploader-browse-link", children: "browse" })
1380
+ ] })
1381
+ ]
1382
+ }
1383
+ ),
1384
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1385
+ "input",
1386
+ {
1387
+ id: inputId,
1388
+ ref: inputRef,
1389
+ type: "file",
1390
+ multiple: !pickerOptions?.maxFiles || pickerOptions.maxFiles > 1,
1391
+ accept,
1392
+ className: "uploader-file-input",
1393
+ "aria-hidden": "true",
1394
+ tabIndex: -1,
1395
+ onChange: handleInputChange
1396
+ }
1397
+ ),
1398
+ files.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("ul", { className: "uploader-file-list", role: "list", "aria-label": "Selected files", children: files.map((entry) => /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("li", { className: `uploader-file-item uploader-file-${entry.state}`, children: [
1399
+ entry.previewUrl && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1400
+ "img",
1401
+ {
1402
+ src: entry.previewUrl,
1403
+ alt: "",
1404
+ "aria-hidden": "true",
1405
+ className: "uploader-file-thumb"
1406
+ }
1407
+ ),
1408
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "uploader-file-info", children: [
1409
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { className: "uploader-file-name", children: entry.file.name }),
1410
+ entry.state === "error" && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { className: "uploader-file-error", role: "alert", children: entry.error ?? "Upload failed" }),
1411
+ entry.state === "done" && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { className: "uploader-file-done", children: "Uploaded" })
1412
+ ] }),
1413
+ entry.state === "uploading" && entry.progress != null && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1414
+ "progress",
1415
+ {
1416
+ className: "uploader-file-progress",
1417
+ value: entry.progress,
1418
+ max: 100,
1419
+ "aria-label": `${entry.file.name} upload progress`
1420
+ }
1421
+ ),
1422
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "uploader-file-actions", children: [
1423
+ entry.state === "error" && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1424
+ "button",
1425
+ {
1426
+ type: "button",
1427
+ className: "uploader-btn-retry",
1428
+ "aria-label": `Retry uploading ${entry.file.name}`,
1429
+ onClick: () => retryFile(entry.id),
1430
+ children: "Retry"
1431
+ }
1432
+ ),
1433
+ entry.state !== "uploading" && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1434
+ "button",
1435
+ {
1436
+ type: "button",
1437
+ className: "uploader-btn-remove",
1438
+ "aria-label": `Remove ${entry.file.name}`,
1439
+ onClick: () => removeFile(entry.id),
1440
+ children: "\xD7"
1441
+ }
1442
+ )
1443
+ ] })
1444
+ ] }, entry.id)) }),
1445
+ isUploading && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "uploader-aggregate-progress", role: "status", "aria-live": "polite", children: [
1446
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("progress", { value: progress, max: 100, "aria-label": "Overall upload progress" }),
1447
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("span", { children: [
1448
+ progress,
1449
+ "%"
1450
+ ] })
1451
+ ] }),
1452
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "uploader-actions", children: [
1453
+ hasPending && !isUploading && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1454
+ "button",
1455
+ {
1456
+ type: "button",
1457
+ className: "uploader-btn-upload",
1458
+ disabled: isUploading,
1459
+ onClick: () => void upload(),
1460
+ children: "Upload"
1461
+ }
1462
+ ),
1463
+ !isDone && !isUploading && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1464
+ "button",
1465
+ {
1466
+ type: "button",
1467
+ className: "uploader-btn-cancel",
1468
+ onClick: onCancel,
1469
+ children: "Cancel"
1470
+ }
1471
+ )
1472
+ ] })
1473
+ ]
1474
+ }
1475
+ );
1476
+ }
1477
+ // Annotate the CommonJS export names for ESM import in node:
1478
+ 0 && (module.exports = {
1479
+ DropPane,
1480
+ ImageEditor,
1481
+ PickerOverlay,
1482
+ UploaderClient,
1483
+ crop,
1484
+ editImage,
1485
+ flip,
1486
+ flop,
1487
+ output,
1488
+ quality,
1489
+ resize,
1490
+ rotate,
1491
+ transformUrl,
1492
+ usePicker
1493
+ });
1494
+ //# sourceMappingURL=index.cjs.map