@ibanzajoe/uploader 1.5.0 → 1.7.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 CHANGED
@@ -26,10 +26,12 @@ __export(src_exports, {
26
26
  PickerOverlay: () => PickerOverlay,
27
27
  UploaderClient: () => UploaderClient,
28
28
  crop: () => crop,
29
+ defaultCameraFacingMode: () => defaultCameraFacingMode,
29
30
  editImage: () => editImage,
30
31
  flip: () => flip,
31
32
  flop: () => flop,
32
33
  isCameraSupported: () => isCameraSupported,
34
+ isMobileDevice: () => isMobileDevice,
33
35
  output: () => output,
34
36
  quality: () => quality,
35
37
  resize: () => resize,
@@ -180,6 +182,7 @@ function xhrPut(url, body, opts) {
180
182
  });
181
183
  }
182
184
  var UploaderClient = class {
185
+ /** The publishable key (`pk_…`) — a project identifier, not a credential. */
183
186
  apikey;
184
187
  apiUrl;
185
188
  security;
@@ -194,7 +197,7 @@ var UploaderClient = class {
194
197
  #capsPromise = null;
195
198
  constructor(options) {
196
199
  this.apikey = options.apikey;
197
- this.apiUrl = (options.apiUrl ?? "https://api.uploaderhq.io").replace(/\/$/, "");
200
+ this.apiUrl = (options.apiUrl ?? "https://api.postila.app").replace(/\/$/, "");
198
201
  this.security = options.security;
199
202
  this.#directUploadOption = options.directUpload;
200
203
  this.#deliveryProtection = options.deliveryProtection;
@@ -306,15 +309,18 @@ var UploaderClient = class {
306
309
  // ─── Auth headers ──────────────────────────────────────────────────────────
307
310
  /**
308
311
  * Build the auth headers shared by all requests.
309
- * Attaches the API key and, when present, the signed policy pair.
312
+ * Attaches the API key and, when present, the signed policy pair. A per-call
313
+ * `security` overrides the client-level one (the file-management methods
314
+ * need policies with different `call` grants than uploads).
310
315
  */
311
- #authHeaders() {
316
+ #authHeaders(security) {
312
317
  const headers = {
313
318
  "X-Uploader-Key": this.apikey
314
319
  };
315
- if (this.security) {
316
- headers["X-Uploader-Policy"] = this.security.policy;
317
- headers["X-Uploader-Signature"] = this.security.signature;
320
+ const pair = security ?? this.security;
321
+ if (pair) {
322
+ headers["X-Uploader-Policy"] = pair.policy;
323
+ headers["X-Uploader-Signature"] = pair.signature;
318
324
  }
319
325
  return headers;
320
326
  }
@@ -434,6 +440,72 @@ var UploaderClient = class {
434
440
  }
435
441
  return this.#uploadMultipart(file, plan.parts, opts);
436
442
  }
443
+ /**
444
+ * List the account's files (paginated, 50 per page).
445
+ *
446
+ * Accounts with required signed policies must supply a policy whose `call`
447
+ * includes `'list'` — a handle-bound `'read'` policy from a delivery URL is
448
+ * deliberately not enough to enumerate the account.
449
+ *
450
+ * @throws {UploaderError} with code NETWORK_ERROR | SERVER_ERROR |
451
+ * CLIENT_ERROR | INVALID_RESPONSE
452
+ */
453
+ async listFiles(query = {}, opts = {}) {
454
+ const params = new URLSearchParams();
455
+ for (const [k, v] of Object.entries(query)) {
456
+ if (v !== void 0) params.set(k, String(v));
457
+ }
458
+ const qs = params.toString();
459
+ const res = await fetchWithRetry(
460
+ `${this.apiUrl}/api/files${qs ? `?${qs}` : ""}`,
461
+ { headers: this.#authHeaders(opts.security) },
462
+ opts.signal
463
+ );
464
+ const body = await res.json().catch(() => null);
465
+ if (!body || !Array.isArray(body.files)) {
466
+ throw new UploaderError("INVALID_RESPONSE", "Unexpected response shape from file list API");
467
+ }
468
+ return body;
469
+ }
470
+ /**
471
+ * Fetch one file's record by its public handle.
472
+ *
473
+ * Accounts with required signed policies must supply a policy whose `call`
474
+ * includes `'read'` (handle-bound policies must match this handle).
475
+ *
476
+ * @throws {UploaderError} — CLIENT_ERROR with statusCode 404 when the handle
477
+ * does not exist (or belongs to another account).
478
+ */
479
+ async getFile(handle, opts = {}) {
480
+ const res = await fetchWithRetry(
481
+ `${this.apiUrl}/api/files/${encodeURIComponent(handle)}`,
482
+ { headers: this.#authHeaders(opts.security) },
483
+ opts.signal
484
+ );
485
+ const body = await res.json().catch(() => null);
486
+ if (!body || typeof body.handle !== "string") {
487
+ throw new UploaderError("INVALID_RESPONSE", "Unexpected response shape from file API");
488
+ }
489
+ return body;
490
+ }
491
+ /**
492
+ * Delete a file by its public handle.
493
+ *
494
+ * The file stops being served immediately (soft-delete + edge cache purge);
495
+ * the stored bytes and derivatives are removed by a background cleanup.
496
+ * Accounts with required signed policies must supply a policy whose `call`
497
+ * includes `'remove'` (handle-bound policies must match this handle).
498
+ *
499
+ * @throws {UploaderError} — CLIENT_ERROR with statusCode 404 when the handle
500
+ * does not exist (or belongs to another account).
501
+ */
502
+ async deleteFile(handle, opts = {}) {
503
+ await fetchWithRetry(
504
+ `${this.apiUrl}/api/files/${encodeURIComponent(handle)}`,
505
+ { method: "DELETE", headers: this.#authHeaders(opts.security) },
506
+ opts.signal
507
+ );
508
+ }
437
509
  /**
438
510
  * Upload multiple files with concurrency limiting.
439
511
  *
@@ -1275,6 +1347,19 @@ var import_jsx_runtime3 = require("react/jsx-runtime");
1275
1347
  function isCameraSupported() {
1276
1348
  return typeof navigator !== "undefined" && !!navigator.mediaDevices && typeof navigator.mediaDevices.getUserMedia === "function" && typeof document !== "undefined" && typeof HTMLCanvasElement !== "undefined";
1277
1349
  }
1350
+ function isMobileDevice() {
1351
+ if (typeof navigator === "undefined") return false;
1352
+ const uaData = navigator.userAgentData;
1353
+ if (typeof uaData?.mobile === "boolean") return uaData.mobile;
1354
+ const ua = navigator.userAgent ?? "";
1355
+ if (/Android|iPhone|iPad|iPod|Windows Phone|webOS|BlackBerry|Opera Mini|Mobile/i.test(ua)) {
1356
+ return true;
1357
+ }
1358
+ return /Macintosh/.test(ua) && (navigator.maxTouchPoints ?? 0) > 1;
1359
+ }
1360
+ function defaultCameraFacingMode() {
1361
+ return isMobileDevice() ? "environment" : "user";
1362
+ }
1278
1363
  var IMAGE_EXT = /\.(png|jpe?g|gif|webp|bmp|svg|avif|heic|heif)$/i;
1279
1364
  function acceptsImages(accept) {
1280
1365
  if (!accept || accept.length === 0) return true;
@@ -1315,7 +1400,7 @@ function describeCameraError(err) {
1315
1400
  function CameraCapture({
1316
1401
  onCapture,
1317
1402
  onCancel,
1318
- facingMode = "user",
1403
+ facingMode,
1319
1404
  outputType = "image/jpeg",
1320
1405
  quality: quality2 = 0.92,
1321
1406
  fileNamePrefix = "camera-photo",
@@ -1328,12 +1413,32 @@ function CameraCapture({
1328
1413
  const [status, setStatus] = (0, import_react4.useState)("initializing");
1329
1414
  const [error, setError] = (0, import_react4.useState)(null);
1330
1415
  const [stillUrl, setStillUrl] = (0, import_react4.useState)("");
1331
- const mirror = facingMode === "user";
1416
+ const [facing, setFacing] = (0, import_react4.useState)(
1417
+ () => facingMode ?? defaultCameraFacingMode()
1418
+ );
1419
+ const [canFlip, setCanFlip] = (0, import_react4.useState)(false);
1420
+ const mirror = facing === "user";
1421
+ (0, import_react4.useEffect)(() => {
1422
+ if (facingMode) setFacing(facingMode);
1423
+ }, [facingMode]);
1332
1424
  const stopStream = (0, import_react4.useCallback)(() => {
1333
1425
  streamRef.current?.getTracks().forEach((t) => t.stop());
1334
1426
  streamRef.current = null;
1335
1427
  if (videoRef.current) videoRef.current.srcObject = null;
1336
1428
  }, []);
1429
+ const probeCameraCount = (0, import_react4.useCallback)(async () => {
1430
+ const media = typeof navigator !== "undefined" ? navigator.mediaDevices : void 0;
1431
+ if (!media || typeof media.enumerateDevices !== "function") {
1432
+ setCanFlip(isMobileDevice());
1433
+ return;
1434
+ }
1435
+ try {
1436
+ const devices = await media.enumerateDevices();
1437
+ setCanFlip(devices.filter((d) => d.kind === "videoinput").length > 1);
1438
+ } catch {
1439
+ setCanFlip(isMobileDevice());
1440
+ }
1441
+ }, []);
1337
1442
  const startStream = (0, import_react4.useCallback)(async () => {
1338
1443
  setError(null);
1339
1444
  setStatus("initializing");
@@ -1342,9 +1447,12 @@ function CameraCapture({
1342
1447
  setStatus("error");
1343
1448
  return;
1344
1449
  }
1450
+ stopStream();
1345
1451
  try {
1346
1452
  const stream = await navigator.mediaDevices.getUserMedia({
1347
- video: { facingMode },
1453
+ // Non-exact: a device with no camera on the requested side falls back to
1454
+ // the one it has rather than throwing OverconstrainedError.
1455
+ video: { facingMode: facing },
1348
1456
  audio: false
1349
1457
  });
1350
1458
  if (videoRef.current == null) {
@@ -1354,11 +1462,12 @@ function CameraCapture({
1354
1462
  streamRef.current = stream;
1355
1463
  videoRef.current.srcObject = stream;
1356
1464
  setStatus("live");
1465
+ void probeCameraCount();
1357
1466
  } catch (err) {
1358
1467
  setError(describeCameraError(err));
1359
1468
  setStatus("error");
1360
1469
  }
1361
- }, [facingMode]);
1470
+ }, [facing, probeCameraCount, stopStream]);
1362
1471
  (0, import_react4.useEffect)(() => {
1363
1472
  void startStream();
1364
1473
  return () => {
@@ -1406,6 +1515,9 @@ function CameraCapture({
1406
1515
  quality2
1407
1516
  );
1408
1517
  }, [mirror, outputType, quality2, stopStream]);
1518
+ const flipCamera = (0, import_react4.useCallback)(() => {
1519
+ setFacing((current) => current === "user" ? "environment" : "user");
1520
+ }, []);
1409
1521
  const retake = (0, import_react4.useCallback)(() => {
1410
1522
  blobRef.current = null;
1411
1523
  setStillUrl("");
@@ -1424,6 +1536,7 @@ function CameraCapture({
1424
1536
  className: ["uploader-camera", className ?? ""].filter(Boolean).join(" "),
1425
1537
  style,
1426
1538
  "data-testid": "camera-capture",
1539
+ "data-facing": facing,
1427
1540
  children: [
1428
1541
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "uploader-camera-stage", children: [
1429
1542
  status === "error" ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "uploader-camera-error", role: "alert", children: error }) : status === "captured" ? (
@@ -1440,7 +1553,50 @@ function CameraCapture({
1440
1553
  "data-mirror": mirror ? "true" : void 0
1441
1554
  }
1442
1555
  ),
1443
- status === "initializing" && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "uploader-camera-status", "aria-live": "polite", children: "Starting camera\u2026" })
1556
+ status === "initializing" && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "uploader-camera-status", "aria-live": "polite", children: "Starting camera\u2026" }),
1557
+ canFlip && (status === "live" || status === "initializing") && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1558
+ "button",
1559
+ {
1560
+ type: "button",
1561
+ className: "uploader-camera-flip",
1562
+ "aria-label": facing === "user" ? "Switch to rear camera" : "Switch to front camera",
1563
+ disabled: status !== "live",
1564
+ onClick: flipCamera,
1565
+ children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("svg", { viewBox: "0 0 24 24", width: "20", height: "20", "aria-hidden": "true", focusable: "false", children: [
1566
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1567
+ "path",
1568
+ {
1569
+ d: "M4 8.5A2.5 2.5 0 0 1 6.5 6h1.2l1-1.7a1 1 0 0 1 .87-.5h4.86a1 1 0 0 1 .86.5l1 1.7h1.21A2.5 2.5 0 0 1 20 8.5v8A2.5 2.5 0 0 1 17.5 19h-11A2.5 2.5 0 0 1 4 16.5v-8Z",
1570
+ fill: "none",
1571
+ stroke: "currentColor",
1572
+ strokeWidth: "1.6",
1573
+ strokeLinejoin: "round"
1574
+ }
1575
+ ),
1576
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1577
+ "path",
1578
+ {
1579
+ d: "M9.4 12.6a2.6 2.6 0 0 0 4.45 1.6M14.6 11.4a2.6 2.6 0 0 0-4.45-1.6",
1580
+ fill: "none",
1581
+ stroke: "currentColor",
1582
+ strokeWidth: "1.6",
1583
+ strokeLinecap: "round"
1584
+ }
1585
+ ),
1586
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1587
+ "path",
1588
+ {
1589
+ d: "m9.4 10.2-.1 2.1 2.1-.6M14.6 13.8l.1-2.1-2.1.6",
1590
+ fill: "none",
1591
+ stroke: "currentColor",
1592
+ strokeWidth: "1.6",
1593
+ strokeLinecap: "round",
1594
+ strokeLinejoin: "round"
1595
+ }
1596
+ )
1597
+ ] })
1598
+ }
1599
+ )
1444
1600
  ] }),
1445
1601
  status === "live" && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: "uploader-camera-hint", children: "Frame your shot, then tap the shutter to capture." }),
1446
1602
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "uploader-camera-actions", children: status === "captured" ? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_jsx_runtime3.Fragment, { children: [
@@ -2132,10 +2288,12 @@ function DropPane({
2132
2288
  PickerOverlay,
2133
2289
  UploaderClient,
2134
2290
  crop,
2291
+ defaultCameraFacingMode,
2135
2292
  editImage,
2136
2293
  flip,
2137
2294
  flop,
2138
2295
  isCameraSupported,
2296
+ isMobileDevice,
2139
2297
  output,
2140
2298
  quality,
2141
2299
  resize,