diff --git a/apps/web/package.json b/apps/web/package.json index df03815c..f283b9d1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,6 +21,7 @@ "@tailwindcss/typography": "^0.5.9", "@tanstack/react-query": "^4.29.7", "@types/react": "^18.2.5", + "@types/react-avatar-editor": "^13.0.0", "@types/react-dom": "^18.2.2", "@types/react-router-dom": "^5.3.3", "@vitejs/plugin-react": "^4.0.0", @@ -30,7 +31,9 @@ "framer-motion": "^10.12.8", "postcss": "^8.4.23", "react": "^18.2.0", + "react-avatar-editor": "^13.0.0", "react-dom": "^18.2.0", + "react-dropzone": "^14.2.3", "react-hook-form": "^7.43.9", "react-router-dom": "^6.11.0", "tailwindcss": "^3.3.2", diff --git a/apps/web/src/components/imageField.tsx b/apps/web/src/components/imageField.tsx index b9a480c4..52fa1de8 100644 --- a/apps/web/src/components/imageField.tsx +++ b/apps/web/src/components/imageField.tsx @@ -4,16 +4,23 @@ import { PhotoIcon } from "@heroicons/react/20/solid"; import { FieldError } from "react-hook-form"; import setRef from "../lib/setRef"; import FormField from "./formField"; +import ImageModal from "./imageModal"; import TextInput from "./textInput"; -type Props = Omit, "value"> & { +type Props = Omit< + React.ComponentProps, + "value" | "onChange" +> & { label?: string; helpText?: string; required?: boolean; children?: ReactNode; className?: string; - value?: string | File | undefined; + value?: string | null | undefined; error?: FieldError; + onChange: (value: HTMLCanvasElement | null) => void; + imageWidth?: number; + imageHeight?: number; }; const fileToDataUrl = (file: File): Promise => { @@ -35,36 +42,53 @@ const fileToDataUrl = (file: File): Promise => { export default forwardRef( ( - { name, label, helpText, required, className, value, error, onChange }, + { + name, + label, + helpText, + required, + className, + value, + error, + onChange, + imageWidth = 250, + imageHeight = 250, + }, ref, ) => { const fileRef = useRef(null); const imageRef = useRef(null); const [_isHover, setHover] = useState(false); const [imageSrc, setImageSrc] = useState(); + const [finalImage, setFinalImage] = useState(); + + const [editorOpen, setEditorOpen] = useState(false); useEffect(() => { - (async () => { - if (value instanceof File) { - setImageSrc(await fileToDataUrl(value)); - } else { - setImageSrc(value || ""); - } - })(); + setImageSrc(value || ""); + setFinalImage(null); }, [value]); - const updatePreview = () => { + const updateImageSrc = () => { const file = Array.from(fileRef.current?.files || []).find(() => true); if (file) { (async () => { - setImageSrc(await fileToDataUrl(file)); + const imageSrc = await fileToDataUrl(file); + setImageSrc(imageSrc); + setEditorOpen(true); })(); } else { if (imageRef.current) imageRef.current.src = ""; setImageSrc(""); + setEditorOpen(true); } }; + const onSave = (image: HTMLCanvasElement | null) => { + setFinalImage(image); + if (onChange) onChange(image); + }; + return ( ( const dt = new DataTransfer(); Array.from(e.dataTransfer.files).forEach((f) => dt.items.add(f)); if (fileRef.current) fileRef.current.files = dt.files; - updatePreview(); + updateImageSrc(); }} >
-
- {imageSrc ? ( +
+ {imageSrc || finalImage ? ( ) : ( - + Tap to Upload an Image @@ -129,26 +163,37 @@ export default forwardRef( ref={(node) => { setRef(fileRef, node); }} + onClick={(e: any) => { + // this forces onChange to fire + e.target.value = null; + }} onChange={(e) => { e.stopPropagation(); - updatePreview(); - if (onChange) onChange(e); + updateImageSrc(); }} /> {/*
- - JPG, GIF or PNG. 1MB max. -
*/} + + JPG, GIF or PNG. 1MB max. +
*/}
+ ); }, diff --git a/apps/web/src/components/imageModal.tsx b/apps/web/src/components/imageModal.tsx new file mode 100644 index 00000000..83b8152d --- /dev/null +++ b/apps/web/src/components/imageModal.tsx @@ -0,0 +1,74 @@ +import { Dialog } from "@headlessui/react"; +import { ArrowPathRoundedSquareIcon } from "@heroicons/react/20/solid"; +import { useRef, useState } from "react"; +import AvatarEditor from "react-avatar-editor"; +import Button from "./button"; + +export default ({ + image, + open, + setOpen, + onSave, + width = 250, + height = 250, +}: { + image: any; + open: boolean; + setOpen: (value: boolean) => void; + onSave: (newImage: any) => void; + width?: number; + height?: number; +}) => { + const ref = useRef(null); + const [rotate, setRotate] = useState(0); + const [scale, setScale] = useState(1); + + return ( + + + + +
+ { + setScale(parseFloat((e as any).target.value)); + }} + /> +
+ + +
+
+
+
+ ); +}; diff --git a/apps/web/src/lib/api.tsx b/apps/web/src/lib/api.tsx index 5c2d0a5d..3b8c3935 100644 --- a/apps/web/src/lib/api.tsx +++ b/apps/web/src/lib/api.tsx @@ -3,9 +3,6 @@ import config from "../config"; type ApiRequestOptions = { method: "GET" | "POST" | "DELETE" | "PUT"; data?: { [name: string]: any } | undefined; - files?: { - [name: string]: Blob; - }; query?: any; }; diff --git a/apps/web/src/lib/blobs.ts b/apps/web/src/lib/blobs.ts new file mode 100644 index 00000000..4a0729b6 --- /dev/null +++ b/apps/web/src/lib/blobs.ts @@ -0,0 +1,7 @@ +export const toBlob = (canvas: HTMLCanvasElement) => { + return new Promise((resolve, reject) => { + canvas.toBlob(async (blob) => { + resolve(blob); + }); + }); +}; diff --git a/apps/web/src/routes/addTasting.tsx b/apps/web/src/routes/addTasting.tsx index cd611526..9f3f5c8b 100644 --- a/apps/web/src/routes/addTasting.tsx +++ b/apps/web/src/routes/addTasting.tsx @@ -19,6 +19,7 @@ import TextAreaField from "../components/textAreaField"; import TextField from "../components/textField"; import { useSuspenseQuery } from "../hooks/useSuspenseQuery"; import api, { ApiError } from "../lib/api"; +import { toBlob } from "../lib/blobs"; import { toTitleCase } from "../lib/strings"; import type { Bottle, Paginated } from "../types"; @@ -47,7 +48,7 @@ export default function AddTasting() { const navigate = useNavigate(); const [error, setError] = useState(); - const [image, setImage] = useState(); + const [picture, setPicture] = useState(null); const { control, @@ -78,11 +79,13 @@ export default function AddTasting() { setError("Internal error"); } } - if (image) { + + if (picture) { + const blob = await toBlob(picture); try { await api.post(`/tastings/${tasting.id}/image`, { data: { - image, + image: blob, }, }); } catch (err) { @@ -154,10 +157,9 @@ export default function AddTasting() { - setImage(e.target.files?.length ? e.target.files[0] : undefined) - } + onChange={(value) => setPicture(value)} + imageWidth={1024 / 2} + imageHeight={768 / 2} />
diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index a61ab7f5..5eec1d9c 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -10,12 +10,12 @@ import TextField from "../components/textField"; import { useRequiredAuth } from "../hooks/useAuth"; import { useSuspenseQuery } from "../hooks/useSuspenseQuery"; import api, { ApiError } from "../lib/api"; +import { toBlob } from "../lib/blobs"; import type { User } from "../types"; type FormData = { username: string; displayName?: string; - picture?: string; }; export default function Settings() { @@ -32,9 +32,7 @@ export default function Settings() { username: user.username, displayName: user.displayName, }); - const [picture, setPicture] = useState( - user.pictureUrl, - ); + const [picture, setPicture] = useState(null); const [error, setError] = useState(); @@ -48,14 +46,25 @@ export default function Settings() { username: formData.username, }, }); - const newAvatar = - picture !== user.pictureUrl - ? await api.post("/users/me/avatar", { - data: { - picture, - }, - }) - : {}; + let newAvatar: any; + if (picture) { + const blob = await toBlob(picture); + newAvatar = await api.post("/users/me/avatar", { + data: { + picture: blob, + }, + }); + } else { + newAvatar = {}; + } + // const newAvatar = + // picture !== user.pictureUrl + // ? await api.post("/users/me/avatar", { + // data: { + // picture, + // }, + // }) + // : {}; updateUser({ ...newUser, ...newAvatar, @@ -101,10 +110,8 @@ export default function Settings() { - setPicture(e.target.files?.length ? e.target.files[0] : undefined) - } + value={user.pictureUrl} + onChange={(value) => setPicture(value)} /> diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 75f274c3..02aac503 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -253,6 +253,9 @@ importers: '@types/react': specifier: ^18.2.5 version: 18.2.5 + '@types/react-avatar-editor': + specifier: ^13.0.0 + version: 13.0.0 '@types/react-dom': specifier: ^18.2.2 version: 18.2.2 @@ -280,9 +283,15 @@ importers: react: specifier: ^18.2.0 version: 18.2.0 + react-avatar-editor: + specifier: ^13.0.0 + version: 13.0.0(@babel/core@7.21.8)(react-dom@18.2.0)(react@18.2.0) react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) + react-dropzone: + specifier: ^14.2.3 + version: 14.2.3(react@18.2.0) react-hook-form: specifier: ^7.43.9 version: 7.43.9(react@18.2.0) @@ -1340,6 +1349,23 @@ packages: '@babel/helper-plugin-utils': 7.21.5 dev: false + /@babel/plugin-transform-runtime@7.21.4(@babel/core@7.21.8): + resolution: {integrity: sha512-1J4dhrw1h1PqnNNpzwxQ2UBymJUF8KuPjAAnlLwZcGhHAIqUigFW7cdK6GHoB64ubY4qXQNYknoUeks4Wz7CUA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-module-imports': 7.21.4 + '@babel/helper-plugin-utils': 7.21.5 + babel-plugin-polyfill-corejs2: 0.3.3(@babel/core@7.21.8) + babel-plugin-polyfill-corejs3: 0.6.0(@babel/core@7.21.8) + babel-plugin-polyfill-regenerator: 0.4.1(@babel/core@7.21.8) + semver: 6.3.0 + transitivePeerDependencies: + - supports-color + dev: false + /@babel/plugin-transform-shorthand-properties@7.18.6(@babel/core@7.21.8): resolution: {integrity: sha512-eCLXXJqv8okzg86ywZJbRn19YJHU4XUa55oz2wbHhaQVn/MM+XhukiT7SYqp/7o00dg52Rj51Ny+Ecw4oyoygw==} engines: {node: '>=6.9.0'} @@ -3995,6 +4021,12 @@ packages: resolution: {integrity: sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==} dev: false + /@types/react-avatar-editor@13.0.0: + resolution: {integrity: sha512-5ymOayy6mfT35xTqzni7UjXvCNEg8/pH4pI5RenITp9PBc02KGTYjSV1WboXiQDYSh5KomLT0ngBLEAIhV1QoQ==} + dependencies: + '@types/react': 18.2.5 + dev: false + /@types/react-dom@18.2.2: resolution: {integrity: sha512-IGuuCsLmAH0f3KksOZp/vkpUtO2YrIwob4YxvoFQR2XvkLL7tf7mLYcXiyG47KgTKngI4+7lNm4dM4eBTbG1Bw==} dependencies: @@ -4555,6 +4587,11 @@ packages: engines: {node: '>=8.0.0'} dev: false + /attr-accept@2.2.2: + resolution: {integrity: sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==} + engines: {node: '>=4'} + dev: false + /autoprefixer@10.4.14(postcss@8.4.23): resolution: {integrity: sha512-FQzyfOsTlwVzjHxKEqRIAdJx9niO6VCBCoEwax/VLSoQF29ggECcPuBqUMZ+u8jCZOPSy8b8/8KnuFbp0SaFZQ==} engines: {node: ^10 || ^12 || >=14} @@ -6166,6 +6203,13 @@ packages: flat-cache: 3.0.4 dev: false + /file-selector@0.6.0: + resolution: {integrity: sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==} + engines: {node: '>= 12'} + dependencies: + tslib: 2.5.0 + dev: false + /filelist@1.0.4: resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} dependencies: @@ -8553,6 +8597,22 @@ packages: strip-json-comments: 2.0.1 dev: false + /react-avatar-editor@13.0.0(@babel/core@7.21.8)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-0xw63MbRRQdDy7YI1IXU9+7tTFxYEFLV8CABvryYOGjZmXRTH2/UA0mafe57ns62uaEFX181kA4XlGlxCaeXKA==} + peerDependencies: + react: ^0.14.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + react-dom: ^0.14.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@babel/plugin-transform-runtime': 7.21.4(@babel/core@7.21.8) + '@babel/runtime': 7.21.5 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + transitivePeerDependencies: + - '@babel/core' + - supports-color + dev: false + /react-dom@18.2.0(react@18.2.0): resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} peerDependencies: @@ -8563,6 +8623,18 @@ packages: scheduler: 0.23.0 dev: false + /react-dropzone@14.2.3(react@18.2.0): + resolution: {integrity: sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==} + engines: {node: '>= 10.13'} + peerDependencies: + react: '>= 16.8 || 18.0.0' + dependencies: + attr-accept: 2.2.2 + file-selector: 0.6.0 + prop-types: 15.8.1 + react: 18.2.0 + dev: false + /react-hook-form@7.43.9(react@18.2.0): resolution: {integrity: sha512-AUDN3Pz2NSeoxQ7Hs6OhQhDr6gtF9YRuutGDwPQqhSUAHJSgGl2VeY3qN19MG0SucpjgDiuMJ4iC5T5uB+eaNQ==} engines: {node: '>=12.22.0'}