Skip to content

Commit 8f1e84b

Browse files
committed
feat(react): <ImageUpload> + adapter context provider + optional form.css export
Adds the react-side half of view.image support: <ImageUpload> (metadata-driven upload/crop/remove control, lazy-loaded react-easy-crop), the <ImageUploadAdapterProvider>/useImageUploadAdapter() context seam (mirrors tanstack's EntityFetcherProvider), cropToBlob, and a package-root form.css with generic --mo-* themable tokens. Consumes ImageUploadAdapter/ImageMeta/ canvasToJpegBlob from @metaobjectsdev/runtime-web (Task 2). react-easy-crop is wired as an optional peer + dev dependency so non-image consumers pay nothing.
1 parent 19876ec commit 8f1e84b

8 files changed

Lines changed: 341 additions & 2 deletions

File tree

bun.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

client/web/packages/react/form.css

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/* Default styling for the metadata-driven form controls emitted by
2+
@metaobjectsdev/codegen-ts-react's formFile generator, plus the
3+
<ImageUpload> component. Single-class specificity so a host app's own
4+
reset/preflight styles don't win. Colors resolve through CSS custom
5+
properties with a fallback hex, so this file is themable and works with
6+
zero configuration out of the box. */
7+
.metaobjects-form { display: grid; gap: 1.25rem; }
8+
9+
.metaobjects-field { display: grid; gap: 0.5rem; }
10+
11+
.metaobjects-field-label {
12+
font-size: 0.72rem;
13+
text-transform: uppercase;
14+
letter-spacing: 0.2em;
15+
color: var(--mo-fg, #eae3d3);
16+
opacity: 0.62;
17+
}
18+
19+
.metaobjects-field-input {
20+
width: 100%;
21+
padding: 0.75rem 1rem;
22+
background: color-mix(in srgb, var(--mo-field-bg, #142a33) 40%, transparent);
23+
border: 1px solid color-mix(in srgb, var(--mo-accent, #c9a467) 28%, transparent);
24+
color: var(--mo-fg, #eae3d3);
25+
font: inherit;
26+
outline: none;
27+
}
28+
.metaobjects-field-input:focus-visible { border-color: var(--mo-accent, #c9a467); }
29+
textarea.metaobjects-field-input { resize: vertical; min-height: 6rem; }
30+
31+
.metaobjects-field-checkbox { width: 1rem; height: 1rem; accent-color: var(--mo-accent, #c9a467); }
32+
.metaobjects-field-radios { display: grid; gap: 0.5rem; border: 0; padding: 0; }
33+
.metaobjects-field-radio { display: flex; gap: 0.5rem; align-items: center; color: var(--mo-fg, #eae3d3); }
34+
35+
.metaobjects-field-error { font-size: 0.8rem; color: var(--mo-accent-soft, #e3cd9f); }
36+
37+
.metaobjects-image-upload { display: grid; gap: 0.75rem; }
38+
.metaobjects-image-preview { max-width: 100%; height: auto; }
39+
.metaobjects-image-actions { display: grid; grid-auto-flow: column; justify-content: start; gap: 0.75rem; }
40+
/* react-easy-crop requires a positioned, sized ancestor or its internal
41+
absolutely-positioned layers collapse to 0x0. This is a functional
42+
minimum, not decoration — override freely (e.g. a taller crop area). */
43+
.metaobjects-image-cropper { position: relative; width: 100%; height: 300px; }
44+
45+
.metaobjects-form-actions { margin-top: 0.5rem; }
46+
.metaobjects-form-submit {
47+
padding: 0.6rem 1.5rem;
48+
background: var(--mo-accent, #c9a467);
49+
border: 1px solid var(--mo-accent, #c9a467);
50+
color: var(--mo-accent-fg, #0f1d24);
51+
font-size: 0.72rem;
52+
text-transform: uppercase;
53+
letter-spacing: 0.22em;
54+
cursor: pointer;
55+
transition: background-color 0.2s;
56+
}
57+
.metaobjects-form-submit:hover { background: var(--mo-accent-soft, #e3cd9f); }
58+
.metaobjects-form-submit:disabled { opacity: 0.6; cursor: not-allowed; }

client/web/packages/react/package.json

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@
1010
"bun": "./src/index.ts",
1111
"types": "./dist/index.d.ts",
1212
"default": "./dist/index.js"
13-
}
13+
},
14+
"./form.css": "./form.css"
1415
},
1516
"files": [
1617
"dist",
1718
"src",
19+
"form.css",
1820
"README.md",
1921
"LICENSE"
2022
],
@@ -50,6 +52,7 @@
5052
"peerDependencies": {
5153
"@hookform/resolvers": ">=3.0.0",
5254
"react": ">=18.0.0",
55+
"react-easy-crop": ">=5.0.0",
5356
"react-hook-form": ">=7.0.0",
5457
"zod": ">=3.23.0"
5558
},
@@ -60,6 +63,9 @@
6063
"react": {
6164
"optional": true
6265
},
66+
"react-easy-crop": {
67+
"optional": true
68+
},
6369
"react-hook-form": {
6470
"optional": true
6571
},
@@ -69,13 +75,14 @@
6975
},
7076
"devDependencies": {
7177
"@hookform/resolvers": "^3.10.0",
72-
"@testing-library/react": "^16.0.0",
7378
"@testing-library/dom": "^10.0.0",
79+
"@testing-library/react": "^16.0.0",
7480
"@types/react": "^19.0.0",
7581
"bun-types": "latest",
7682
"jsdom": "^29.1.1",
7783
"react": "^19.0.0",
7884
"react-dom": "^19.0.0",
85+
"react-easy-crop": "^5.0.0",
7986
"react-hook-form": "^7.54.0",
8087
"typescript": "^5.6.0",
8188
"zod": "^4.4.3"
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { Area } from "react-easy-crop";
2+
import { canvasToJpegBlob } from "@metaobjectsdev/runtime-web";
3+
4+
/**
5+
* Draws the selected crop rectangle of `src` onto a canvas bounded to
6+
* `maxEdge` on its longest side, then re-encodes to JPEG. The canvas
7+
* redraw drops all EXIF/GPS metadata as a side effect.
8+
*/
9+
export async function cropToBlob(src: string, area: Area, maxEdge: number): Promise<Blob> {
10+
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
11+
const el = new Image();
12+
el.onload = () => resolve(el);
13+
el.onerror = reject;
14+
el.src = src;
15+
});
16+
const scale = Math.min(1, maxEdge / Math.max(area.width, area.height));
17+
const w = Math.round(area.width * scale);
18+
const h = Math.round(area.height * scale);
19+
const canvas = document.createElement("canvas");
20+
canvas.width = w;
21+
canvas.height = h;
22+
const ctx = canvas.getContext("2d");
23+
if (!ctx) throw new Error("canvas 2d context unavailable");
24+
ctx.drawImage(img, area.x, area.y, area.width, area.height, 0, 0, w, h);
25+
return canvasToJpegBlob(canvas, 0.9);
26+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { createContext, useContext, type ReactNode } from "react";
2+
import type { ImageUploadAdapter } from "@metaobjectsdev/runtime-web";
3+
4+
const ImageUploadAdapterContext = createContext<ImageUploadAdapter | null>(null);
5+
6+
export interface ImageUploadAdapterProviderProps {
7+
value: ImageUploadAdapter;
8+
children: ReactNode;
9+
}
10+
11+
/** Wrap your app (or the relevant subtree) to supply an upload/serve adapter to <ImageUpload>. */
12+
export function ImageUploadAdapterProvider({ value, children }: ImageUploadAdapterProviderProps) {
13+
return <ImageUploadAdapterContext.Provider value={value}>{children}</ImageUploadAdapterContext.Provider>;
14+
}
15+
16+
/** Reads the image upload adapter from context. Throws if not provided. */
17+
export function useImageUploadAdapter(): ImageUploadAdapter {
18+
const adapter = useContext(ImageUploadAdapterContext);
19+
if (!adapter) {
20+
throw new Error(
21+
"useImageUploadAdapter() called outside <ImageUploadAdapterProvider>. " +
22+
"Wrap your app (or the relevant subtree) with ImageUploadAdapterProvider value={...}.",
23+
);
24+
}
25+
return adapter;
26+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { lazy, Suspense, useCallback, useRef, useState } from "react";
2+
import type { ChangeEvent, ReactElement } from "react";
3+
import type { Area } from "react-easy-crop";
4+
import type { ImageMeta } from "@metaobjectsdev/runtime-web";
5+
import { useImageUploadAdapter } from "./image-adapter-provider.js";
6+
import { cropToBlob } from "./crop-to-blob.js";
7+
8+
// react-easy-crop is an optional peer dependency — loaded lazily so consumers
9+
// who don't use <ImageUpload> never pay for it.
10+
const Cropper = lazy(() => import("react-easy-crop"));
11+
12+
export interface ImageUploadProps {
13+
/** Current stored image key, or null/undefined when no image is set. */
14+
value?: string | null;
15+
/** Called with the new stored image key, or null when the image is removed. */
16+
onChange: (key: string | null) => void;
17+
/** Resolved view.image attrs (aspectRatio / maxEdge / store / accept / maxBytes). */
18+
meta: ImageMeta;
19+
}
20+
21+
/**
22+
* Metadata-driven image upload + crop control. Renders a preview plus
23+
* Upload/Replace/Remove actions when idle, and a lazy-loaded crop UI
24+
* (react-easy-crop) while a picked file is being cropped. On save, crops the
25+
* selection to a bounded JPEG and hands the blob to the adapter supplied via
26+
* <ImageUploadAdapterProvider> — this component has no knowledge of the
27+
* storage backend, upload endpoint, or auth.
28+
*
29+
* Bound by the generated form's `view.image` field; usable standalone too.
30+
*/
31+
export function ImageUpload({ value, onChange, meta }: ImageUploadProps): ReactElement {
32+
const adapter = useImageUploadAdapter();
33+
const [editing, setEditing] = useState<string | null>(null); // object URL being cropped
34+
const [crop, setCrop] = useState({ x: 0, y: 0 });
35+
const [zoom, setZoom] = useState(1);
36+
const areaRef = useRef<Area | null>(null);
37+
const [busy, setBusy] = useState(false);
38+
const [error, setError] = useState<string | null>(null);
39+
const accept = (meta.accept ?? ["image/jpeg", "image/png", "image/webp"]).join(",");
40+
41+
const onPick = (e: ChangeEvent<HTMLInputElement>) => {
42+
const file = e.target.files?.[0];
43+
if (!file) return;
44+
if (meta.maxBytes && file.size > meta.maxBytes) return; // client guard; server re-enforces
45+
setError(null);
46+
setEditing(URL.createObjectURL(file));
47+
};
48+
49+
const onSave = useCallback(async () => {
50+
if (!editing || !areaRef.current) return;
51+
setBusy(true);
52+
setError(null);
53+
try {
54+
const blob = await cropToBlob(editing, areaRef.current, meta.maxEdge ?? 2000);
55+
// exactOptionalPropertyTypes: only forward `store` when the caller supplied one.
56+
const { key } = await adapter.upload(blob, { ...(meta.store !== undefined && { store: meta.store }) });
57+
onChange(key);
58+
URL.revokeObjectURL(editing);
59+
setEditing(null);
60+
} catch {
61+
// Crop/upload failed (adapter rejected the image, network drop, etc).
62+
// Leave `editing` in place so the user's crop selection isn't lost —
63+
// they can retry Save crop without re-picking the file.
64+
setError("Something went wrong saving that image. Try again.");
65+
} finally {
66+
setBusy(false);
67+
}
68+
}, [editing, meta.maxEdge, meta.store, adapter, onChange]);
69+
70+
return (
71+
<div className="metaobjects-image-upload">
72+
{value && !editing && (
73+
<img src={adapter.imageUrl(value)} alt="" className="metaobjects-image-preview" />
74+
)}
75+
{!editing && (
76+
<div className="metaobjects-image-actions">
77+
<label className="metaobjects-form-submit">
78+
{value ? "Replace" : "Upload"}
79+
<input type="file" accept={accept} onChange={onPick} hidden />
80+
</label>
81+
{value && (
82+
<button type="button" className="metaobjects-form-submit" onClick={() => onChange(null)}>
83+
Remove
84+
</button>
85+
)}
86+
</div>
87+
)}
88+
{editing && (
89+
<div className="metaobjects-image-cropper">
90+
<Suspense fallback={<p>Loading cropper…</p>}>
91+
<div style={{ position: "relative", height: 320 }}>
92+
<Cropper
93+
image={editing}
94+
crop={crop}
95+
zoom={zoom}
96+
aspect={meta.aspectRatio}
97+
onCropChange={setCrop}
98+
onZoomChange={setZoom}
99+
onCropComplete={(_croppedArea, croppedAreaPixels) => (areaRef.current = croppedAreaPixels)}
100+
/>
101+
</div>
102+
</Suspense>
103+
<div className="metaobjects-image-actions">
104+
<button type="button" className="metaobjects-form-submit" disabled={busy} onClick={onSave}>
105+
{busy ? "Saving…" : "Save crop"}
106+
</button>
107+
<button
108+
type="button"
109+
className="metaobjects-form-submit"
110+
disabled={busy}
111+
onClick={() => {
112+
URL.revokeObjectURL(editing);
113+
setEditing(null);
114+
}}
115+
>
116+
Cancel
117+
</button>
118+
</div>
119+
{error && (
120+
<span className="metaobjects-field-error" role="alert">
121+
{error}
122+
</span>
123+
)}
124+
</div>
125+
)}
126+
</div>
127+
);
128+
}

client/web/packages/react/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,10 @@ export {
99
type UseEntityFormReturn,
1010
} from "./use-entity-form.js";
1111
export { CurrencyInput, type CurrencyInputProps } from "./currency-input.js";
12+
export { ImageUpload, type ImageUploadProps } from "./image-upload.js";
13+
export {
14+
ImageUploadAdapterProvider,
15+
useImageUploadAdapter,
16+
type ImageUploadAdapterProviderProps,
17+
} from "./image-adapter-provider.js";
18+
export { cropToBlob } from "./crop-to-blob.js";

0 commit comments

Comments
 (0)