@ibanzajoe/uploader 1.4.0 → 1.6.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/README.md CHANGED
@@ -201,12 +201,23 @@ const url = getSignedDeliveryUrl({
201
201
  handle: file.handle,
202
202
  secret: process.env.UPLOADER_API_SECRET, // the API key's secret
203
203
  baseUrl: 'https://edge.your-domain.com', // origin of FileResult.url
204
- expiresIn: 300, // seconds (default 300)
204
+ expiresIn: 3600, // minimum seconds valid (default 300)
205
+ // stableWindow: 3600, // URL is byte-identical within this window
205
206
  // ops: [resize({ w: 400 }), output({ format: 'webp' })], // optional transform
206
207
  })
207
208
  // → https://edge.…/file/<handle>?policy=…&signature=… (hand to <img src>)
208
209
  ```
209
210
 
211
+ Calling this per view is fine and expected. The expiry is **quantized** to
212
+ `stableWindow` (defaults to `expiresIn`), so repeated calls inside one window
213
+ return the exact same URL string — which is what lets the browser cache the
214
+ image instead of re-fetching it on every render. Without that, each render
215
+ produces a different URL, and to a browser a different URL is a different
216
+ image.
217
+
218
+ Longer windows cache better; shorter windows revoke sooner. Pass
219
+ `stableWindow: 0` to opt out and get an exact per-call expiry.
220
+
210
221
  Prefer to build it yourself? `withSignedPolicy(url, { policy, signature })` from
211
222
  `@ibanzajoe/uploader/core` appends a `policy`/`signature` pair you produced.
212
223
 
@@ -270,6 +281,11 @@ flows through the exact same pipeline as a dropped or browsed file — it lands
270
281
  the queue with a preview, can be cropped/rotated in the in-picker editor, and is
271
282
  then uploaded normally.
272
283
 
284
+ **Front or rear camera.** On phones and tablets the picker opens the **rear**
285
+ camera; on desktops it opens the front one. Where the device exposes more than
286
+ one camera, a flip control in the corner of the viewfinder switches between them,
287
+ so the default is only the starting side. Set `cameraFacingMode` to pin it.
288
+
273
289
  ```tsx
274
290
  <PickerOverlay
275
291
  apikey="pk_…"
@@ -279,7 +295,8 @@ then uploaded normally.
279
295
  accept: ['image/*'],
280
296
  // Sources offered to the user. Omit to offer everything supported.
281
297
  fromSources: ['local_file_system', 'camera'],
282
- // 'user' = front/selfie (mirrored, default) · 'environment' = rear camera
298
+ // Omit for the per-device default (rear on mobile, front on desktop).
299
+ // 'user' = front/selfie (mirrored) · 'environment' = rear camera
283
300
  cameraFacingMode: 'environment',
284
301
  }}
285
302
  />
@@ -290,8 +307,13 @@ Notes:
290
307
  permission. Permission/no-camera errors are surfaced inline with a retry.
291
308
  - Pass `fromSources: ['local_file_system']` to hide the camera even where it is
292
309
  supported; omit `fromSources` to offer it by default.
310
+ - The flip control appears only when `enumerateDevices()` reports two or more
311
+ video inputs (checked after permission is granted, when the list is accurate).
312
+ - The front camera is mirrored in both the preview and the captured still; the
313
+ rear camera is not.
293
314
  - The `<CameraCapture>` component and the `isCameraSupported()` /
294
- `shouldOfferCamera()` helpers are exported for fully custom pickers.
315
+ `shouldOfferCamera()` / `isMobileDevice()` / `defaultCameraFacingMode()`
316
+ helpers are exported for fully custom pickers.
295
317
 
296
318
  ## Signed policies
297
319
 
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,
@@ -1275,6 +1277,19 @@ var import_jsx_runtime3 = require("react/jsx-runtime");
1275
1277
  function isCameraSupported() {
1276
1278
  return typeof navigator !== "undefined" && !!navigator.mediaDevices && typeof navigator.mediaDevices.getUserMedia === "function" && typeof document !== "undefined" && typeof HTMLCanvasElement !== "undefined";
1277
1279
  }
1280
+ function isMobileDevice() {
1281
+ if (typeof navigator === "undefined") return false;
1282
+ const uaData = navigator.userAgentData;
1283
+ if (typeof uaData?.mobile === "boolean") return uaData.mobile;
1284
+ const ua = navigator.userAgent ?? "";
1285
+ if (/Android|iPhone|iPad|iPod|Windows Phone|webOS|BlackBerry|Opera Mini|Mobile/i.test(ua)) {
1286
+ return true;
1287
+ }
1288
+ return /Macintosh/.test(ua) && (navigator.maxTouchPoints ?? 0) > 1;
1289
+ }
1290
+ function defaultCameraFacingMode() {
1291
+ return isMobileDevice() ? "environment" : "user";
1292
+ }
1278
1293
  var IMAGE_EXT = /\.(png|jpe?g|gif|webp|bmp|svg|avif|heic|heif)$/i;
1279
1294
  function acceptsImages(accept) {
1280
1295
  if (!accept || accept.length === 0) return true;
@@ -1315,7 +1330,7 @@ function describeCameraError(err) {
1315
1330
  function CameraCapture({
1316
1331
  onCapture,
1317
1332
  onCancel,
1318
- facingMode = "user",
1333
+ facingMode,
1319
1334
  outputType = "image/jpeg",
1320
1335
  quality: quality2 = 0.92,
1321
1336
  fileNamePrefix = "camera-photo",
@@ -1328,12 +1343,32 @@ function CameraCapture({
1328
1343
  const [status, setStatus] = (0, import_react4.useState)("initializing");
1329
1344
  const [error, setError] = (0, import_react4.useState)(null);
1330
1345
  const [stillUrl, setStillUrl] = (0, import_react4.useState)("");
1331
- const mirror = facingMode === "user";
1346
+ const [facing, setFacing] = (0, import_react4.useState)(
1347
+ () => facingMode ?? defaultCameraFacingMode()
1348
+ );
1349
+ const [canFlip, setCanFlip] = (0, import_react4.useState)(false);
1350
+ const mirror = facing === "user";
1351
+ (0, import_react4.useEffect)(() => {
1352
+ if (facingMode) setFacing(facingMode);
1353
+ }, [facingMode]);
1332
1354
  const stopStream = (0, import_react4.useCallback)(() => {
1333
1355
  streamRef.current?.getTracks().forEach((t) => t.stop());
1334
1356
  streamRef.current = null;
1335
1357
  if (videoRef.current) videoRef.current.srcObject = null;
1336
1358
  }, []);
1359
+ const probeCameraCount = (0, import_react4.useCallback)(async () => {
1360
+ const media = typeof navigator !== "undefined" ? navigator.mediaDevices : void 0;
1361
+ if (!media || typeof media.enumerateDevices !== "function") {
1362
+ setCanFlip(isMobileDevice());
1363
+ return;
1364
+ }
1365
+ try {
1366
+ const devices = await media.enumerateDevices();
1367
+ setCanFlip(devices.filter((d) => d.kind === "videoinput").length > 1);
1368
+ } catch {
1369
+ setCanFlip(isMobileDevice());
1370
+ }
1371
+ }, []);
1337
1372
  const startStream = (0, import_react4.useCallback)(async () => {
1338
1373
  setError(null);
1339
1374
  setStatus("initializing");
@@ -1342,9 +1377,12 @@ function CameraCapture({
1342
1377
  setStatus("error");
1343
1378
  return;
1344
1379
  }
1380
+ stopStream();
1345
1381
  try {
1346
1382
  const stream = await navigator.mediaDevices.getUserMedia({
1347
- video: { facingMode },
1383
+ // Non-exact: a device with no camera on the requested side falls back to
1384
+ // the one it has rather than throwing OverconstrainedError.
1385
+ video: { facingMode: facing },
1348
1386
  audio: false
1349
1387
  });
1350
1388
  if (videoRef.current == null) {
@@ -1354,11 +1392,12 @@ function CameraCapture({
1354
1392
  streamRef.current = stream;
1355
1393
  videoRef.current.srcObject = stream;
1356
1394
  setStatus("live");
1395
+ void probeCameraCount();
1357
1396
  } catch (err) {
1358
1397
  setError(describeCameraError(err));
1359
1398
  setStatus("error");
1360
1399
  }
1361
- }, [facingMode]);
1400
+ }, [facing, probeCameraCount, stopStream]);
1362
1401
  (0, import_react4.useEffect)(() => {
1363
1402
  void startStream();
1364
1403
  return () => {
@@ -1406,6 +1445,9 @@ function CameraCapture({
1406
1445
  quality2
1407
1446
  );
1408
1447
  }, [mirror, outputType, quality2, stopStream]);
1448
+ const flipCamera = (0, import_react4.useCallback)(() => {
1449
+ setFacing((current) => current === "user" ? "environment" : "user");
1450
+ }, []);
1409
1451
  const retake = (0, import_react4.useCallback)(() => {
1410
1452
  blobRef.current = null;
1411
1453
  setStillUrl("");
@@ -1424,6 +1466,7 @@ function CameraCapture({
1424
1466
  className: ["uploader-camera", className ?? ""].filter(Boolean).join(" "),
1425
1467
  style,
1426
1468
  "data-testid": "camera-capture",
1469
+ "data-facing": facing,
1427
1470
  children: [
1428
1471
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "uploader-camera-stage", children: [
1429
1472
  status === "error" ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "uploader-camera-error", role: "alert", children: error }) : status === "captured" ? (
@@ -1440,7 +1483,50 @@ function CameraCapture({
1440
1483
  "data-mirror": mirror ? "true" : void 0
1441
1484
  }
1442
1485
  ),
1443
- status === "initializing" && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "uploader-camera-status", "aria-live": "polite", children: "Starting camera\u2026" })
1486
+ status === "initializing" && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "uploader-camera-status", "aria-live": "polite", children: "Starting camera\u2026" }),
1487
+ canFlip && (status === "live" || status === "initializing") && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1488
+ "button",
1489
+ {
1490
+ type: "button",
1491
+ className: "uploader-camera-flip",
1492
+ "aria-label": facing === "user" ? "Switch to rear camera" : "Switch to front camera",
1493
+ disabled: status !== "live",
1494
+ onClick: flipCamera,
1495
+ children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("svg", { viewBox: "0 0 24 24", width: "20", height: "20", "aria-hidden": "true", focusable: "false", children: [
1496
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1497
+ "path",
1498
+ {
1499
+ 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",
1500
+ fill: "none",
1501
+ stroke: "currentColor",
1502
+ strokeWidth: "1.6",
1503
+ strokeLinejoin: "round"
1504
+ }
1505
+ ),
1506
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1507
+ "path",
1508
+ {
1509
+ 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",
1510
+ fill: "none",
1511
+ stroke: "currentColor",
1512
+ strokeWidth: "1.6",
1513
+ strokeLinecap: "round"
1514
+ }
1515
+ ),
1516
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1517
+ "path",
1518
+ {
1519
+ d: "m9.4 10.2-.1 2.1 2.1-.6M14.6 13.8l.1-2.1-2.1.6",
1520
+ fill: "none",
1521
+ stroke: "currentColor",
1522
+ strokeWidth: "1.6",
1523
+ strokeLinecap: "round",
1524
+ strokeLinejoin: "round"
1525
+ }
1526
+ )
1527
+ ] })
1528
+ }
1529
+ )
1444
1530
  ] }),
1445
1531
  status === "live" && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: "uploader-camera-hint", children: "Frame your shot, then tap the shutter to capture." }),
1446
1532
  /* @__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 +2218,12 @@ function DropPane({
2132
2218
  PickerOverlay,
2133
2219
  UploaderClient,
2134
2220
  crop,
2221
+ defaultCameraFacingMode,
2135
2222
  editImage,
2136
2223
  flip,
2137
2224
  flop,
2138
2225
  isCameraSupported,
2226
+ isMobileDevice,
2139
2227
  output,
2140
2228
  quality,
2141
2229
  resize,