@unicitylabs/sphere-ui 0.1.26 → 0.1.28

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.d.ts CHANGED
@@ -314,9 +314,6 @@ interface MediaUploaderProps {
314
314
  * locally, render a blob-URL preview, and call onFileSelected(file). The
315
315
  * consumer is responsible for uploading later (e.g. after creating the
316
316
  * parent entity to get a real ownerId).
317
- *
318
- * In this mode the URL paste fallback is hidden — the parent entity does
319
- * not yet exist, so an external URL can't be persisted to it anyway.
320
317
  */
321
318
  deferUpload?: boolean;
322
319
  onFileSelected?: (file: File | null) => void;
package/dist/index.js CHANGED
@@ -1422,6 +1422,48 @@ async function readImageSize(file) {
1422
1422
  return null;
1423
1423
  }
1424
1424
  }
1425
+ async function fitImage(file, limit) {
1426
+ if (file.type === "image/svg+xml") return file;
1427
+ if (typeof createImageBitmap !== "function" || typeof document === "undefined") return file;
1428
+ try {
1429
+ const bitmap = await createImageBitmap(file);
1430
+ const sw = bitmap.width;
1431
+ const sh = bitmap.height;
1432
+ let cropW = sw, cropH = sh, cropX = 0, cropY = 0;
1433
+ if (limit.aspectRatio) {
1434
+ if (sw / sh > limit.aspectRatio) {
1435
+ cropW = Math.round(sh * limit.aspectRatio);
1436
+ cropX = Math.round((sw - cropW) / 2);
1437
+ } else if (sw / sh < limit.aspectRatio) {
1438
+ cropH = Math.round(sw / limit.aspectRatio);
1439
+ cropY = Math.round((sh - cropH) / 2);
1440
+ }
1441
+ }
1442
+ const scale = Math.min(1, (limit.maxWidth ?? cropW) / cropW, (limit.maxHeight ?? cropH) / cropH);
1443
+ const outW = Math.max(1, Math.round(cropW * scale));
1444
+ const outH = Math.max(1, Math.round(cropH * scale));
1445
+ if (cropX === 0 && cropY === 0 && cropW === sw && cropH === sh && scale === 1) {
1446
+ bitmap.close?.();
1447
+ return file;
1448
+ }
1449
+ const canvas = document.createElement("canvas");
1450
+ canvas.width = outW;
1451
+ canvas.height = outH;
1452
+ const ctx = canvas.getContext("2d");
1453
+ if (!ctx) {
1454
+ bitmap.close?.();
1455
+ return file;
1456
+ }
1457
+ ctx.drawImage(bitmap, cropX, cropY, cropW, cropH, 0, 0, outW, outH);
1458
+ bitmap.close?.();
1459
+ const mime = file.type === "image/jpeg" || file.type === "image/webp" ? file.type : "image/png";
1460
+ const blob = await new Promise((resolve) => canvas.toBlob(resolve, mime, 0.92));
1461
+ if (!blob) return file;
1462
+ return new File([blob], file.name, { type: mime });
1463
+ } catch {
1464
+ return file;
1465
+ }
1466
+ }
1425
1467
  function MediaUploader({
1426
1468
  kind,
1427
1469
  ownerType,
@@ -1436,6 +1478,7 @@ function MediaUploader({
1436
1478
  const limit = MEDIA_LIMITS[kind];
1437
1479
  const [state, setState] = useState7({ phase: "idle" });
1438
1480
  const [urlInput, setUrlInput] = useState7(value && !value.startsWith("blob:") ? value : "");
1481
+ const [source, setSource] = useState7("upload");
1439
1482
  const previewRef = useRef3(null);
1440
1483
  const fileInputRef = useRef3(null);
1441
1484
  const isPlaceholder = value?.includes("placehold.co") ?? false;
@@ -1464,8 +1507,10 @@ function MediaUploader({
1464
1507
  });
1465
1508
  return;
1466
1509
  }
1467
- if (file.type !== "image/svg+xml") {
1468
- const size = await readImageSize(file);
1510
+ setUrlInput("");
1511
+ const fitted = await fitImage(file, limit);
1512
+ if (fitted.type !== "image/svg+xml") {
1513
+ const size = await readImageSize(fitted);
1469
1514
  if (size) {
1470
1515
  const { width, height } = size;
1471
1516
  if (limit.maxWidth && width > limit.maxWidth || limit.maxHeight && height > limit.maxHeight) {
@@ -1490,18 +1535,18 @@ function MediaUploader({
1490
1535
  }
1491
1536
  if (deferUpload) {
1492
1537
  if (previewRef.current) URL.revokeObjectURL(previewRef.current);
1493
- previewRef.current = URL.createObjectURL(file);
1494
- setState({ phase: "pending", file });
1495
- onFileSelected?.(file);
1538
+ previewRef.current = URL.createObjectURL(fitted);
1539
+ setState({ phase: "pending", file: fitted });
1540
+ onFileSelected?.(fitted);
1496
1541
  onChange(null);
1497
1542
  return;
1498
1543
  }
1499
1544
  const abort = new AbortController();
1500
- setState({ phase: "uploading", file, progress: 0, abort });
1545
+ setState({ phase: "uploading", file: fitted, progress: 0, abort });
1501
1546
  if (previewRef.current) URL.revokeObjectURL(previewRef.current);
1502
- previewRef.current = URL.createObjectURL(file);
1547
+ previewRef.current = URL.createObjectURL(fitted);
1503
1548
  try {
1504
- const result = await uploadFn(file, {
1549
+ const result = await uploadFn(fitted, {
1505
1550
  kind,
1506
1551
  ownerType,
1507
1552
  ownerId,
@@ -1566,9 +1611,26 @@ function MediaUploader({
1566
1611
  setUrlInput("");
1567
1612
  if (deferUpload) onFileSelected?.(null);
1568
1613
  };
1614
+ const commitUrl = () => {
1615
+ const url = urlInput.trim();
1616
+ onChange(url || null);
1617
+ if (url) {
1618
+ if (previewRef.current) {
1619
+ URL.revokeObjectURL(previewRef.current);
1620
+ previewRef.current = null;
1621
+ }
1622
+ setState({ phase: "idle" });
1623
+ if (deferUpload) onFileSelected?.(null);
1624
+ }
1625
+ };
1626
+ const tabClass = (active) => `px-3 py-1 rounded-md transition-colors ${active ? "bg-white dark:bg-white/10 text-neutral-900 dark:text-white shadow-sm" : "text-neutral-500 dark:text-white/45 hover:text-neutral-700 dark:hover:text-white/70"}`;
1569
1627
  return /* @__PURE__ */ jsxs18("div", { className: "space-y-2", children: [
1570
1628
  label && /* @__PURE__ */ jsx23("div", { className: "text-sm text-neutral-700 dark:text-white/70", children: label }),
1571
- /* @__PURE__ */ jsxs18(
1629
+ /* @__PURE__ */ jsxs18("div", { className: "inline-flex gap-0.5 rounded-lg p-0.5 bg-neutral-100 dark:bg-white/5 text-xs w-fit", children: [
1630
+ /* @__PURE__ */ jsx23("button", { type: "button", onClick: () => setSource("upload"), className: tabClass(source === "upload"), children: "Upload" }),
1631
+ /* @__PURE__ */ jsx23("button", { type: "button", onClick: () => setSource("url"), className: tabClass(source === "url"), children: "URL" })
1632
+ ] }),
1633
+ source === "upload" && /* @__PURE__ */ jsxs18(
1572
1634
  "div",
1573
1635
  {
1574
1636
  ...getRootProps(),
@@ -1674,19 +1736,16 @@ function MediaUploader({
1674
1736
  ]
1675
1737
  }
1676
1738
  ),
1677
- !deferUpload && /* @__PURE__ */ jsxs18(Fragment4, { children: [
1678
- /* @__PURE__ */ jsx23("div", { className: "text-xs text-neutral-500 dark:text-white/45", children: "or paste URL:" }),
1679
- /* @__PURE__ */ jsx23(
1680
- Input,
1681
- {
1682
- type: "url",
1683
- placeholder: "https://...",
1684
- value: urlInput,
1685
- onChange: (e) => setUrlInput(e.target.value),
1686
- onBlur: () => onChange(urlInput.trim() || null)
1687
- }
1688
- )
1689
- ] })
1739
+ source === "url" && /* @__PURE__ */ jsx23(
1740
+ Input,
1741
+ {
1742
+ type: "url",
1743
+ placeholder: "https://...",
1744
+ value: urlInput,
1745
+ onChange: (e) => setUrlInput(e.target.value),
1746
+ onBlur: commitUrl
1747
+ }
1748
+ )
1690
1749
  ] });
1691
1750
  }
1692
1751
 
@@ -1741,18 +1800,18 @@ function MediaGallery({ ownerType, ownerId, items, onChange, uploadFn, max = 10,
1741
1800
  };
1742
1801
  function handleDragEnd(e) {
1743
1802
  if (!e.over || e.active.id === e.over.id) return;
1744
- if (deferUpload) {
1745
- const oldIndex = pending.findIndex((p) => p.preview === e.active.id);
1746
- const newIndex = pending.findIndex((p) => p.preview === e.over.id);
1747
- emitPending(arrayMove(pending, oldIndex, newIndex));
1748
- } else {
1803
+ if (items.some((i) => i.url === e.active.id)) {
1749
1804
  const oldIndex = items.findIndex((i) => i.url === e.active.id);
1750
1805
  const newIndex = items.findIndex((i) => i.url === e.over.id);
1751
- onChange(arrayMove(items, oldIndex, newIndex));
1806
+ if (newIndex >= 0) onChange(arrayMove(items, oldIndex, newIndex));
1807
+ } else if (deferUpload) {
1808
+ const oldIndex = pending.findIndex((p) => p.preview === e.active.id);
1809
+ const newIndex = pending.findIndex((p) => p.preview === e.over.id);
1810
+ if (newIndex >= 0) emitPending(arrayMove(pending, oldIndex, newIndex));
1752
1811
  }
1753
1812
  }
1754
- const count = deferUpload ? pending.length : items.length;
1755
- const tileIds = deferUpload ? pending.map((p) => p.preview) : items.map((i) => i.url);
1813
+ const count = items.length + (deferUpload ? pending.length : 0);
1814
+ const tileIds = [...items.map((i) => i.url), ...deferUpload ? pending.map((p) => p.preview) : []];
1756
1815
  return /* @__PURE__ */ jsxs19(
1757
1816
  "div",
1758
1817
  {
@@ -1768,7 +1827,15 @@ function MediaGallery({ ownerType, ownerId, items, onChange, uploadFn, max = 10,
1768
1827
  ")"
1769
1828
  ] }),
1770
1829
  /* @__PURE__ */ jsx24(DndContext, { collisionDetection: closestCenter, onDragEnd: handleDragEnd, children: /* @__PURE__ */ jsx24(SortableContext, { items: tileIds, strategy: horizontalListSortingStrategy, children: /* @__PURE__ */ jsxs19("div", { className: "flex flex-wrap gap-2", children: [
1771
- deferUpload ? pending.map((p, i) => /* @__PURE__ */ jsx24(
1830
+ items.map((item, i) => /* @__PURE__ */ jsx24(
1831
+ SortableTile,
1832
+ {
1833
+ item,
1834
+ onRemove: () => onChange(items.filter((_, j) => j !== i))
1835
+ },
1836
+ item.url
1837
+ )),
1838
+ deferUpload && pending.map((p, i) => /* @__PURE__ */ jsx24(
1772
1839
  SortableTile,
1773
1840
  {
1774
1841
  item: { type: "screenshot", url: p.preview },
@@ -1778,13 +1845,6 @@ function MediaGallery({ ownerType, ownerId, items, onChange, uploadFn, max = 10,
1778
1845
  }
1779
1846
  },
1780
1847
  p.preview
1781
- )) : items.map((item, i) => /* @__PURE__ */ jsx24(
1782
- SortableTile,
1783
- {
1784
- item,
1785
- onRemove: () => onChange(items.filter((_, j) => j !== i))
1786
- },
1787
- item.url
1788
1848
  )),
1789
1849
  count < max && !adding && /* @__PURE__ */ jsx24(
1790
1850
  "button",
@@ -1813,13 +1873,9 @@ function MediaGallery({ ownerType, ownerId, items, onChange, uploadFn, max = 10,
1813
1873
  }
1814
1874
  setAdding(false);
1815
1875
  } : void 0,
1816
- onChange: deferUpload ? () => {
1817
- } : (url) => {
1818
- if (url) {
1819
- const isDuplicate = items.some((i) => i.url === url);
1820
- if (!isDuplicate) {
1821
- onChange([...items, { type: "screenshot", url }]);
1822
- }
1876
+ onChange: (url) => {
1877
+ if (url && !items.some((i) => i.url === url)) {
1878
+ onChange([...items, { type: "screenshot", url }]);
1823
1879
  }
1824
1880
  setAdding(false);
1825
1881
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unicitylabs/sphere-ui",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",