diff --git a/components/ImageUploader.tsx b/components/ImageUploader.tsx index dc2a9954..a4085a11 100644 --- a/components/ImageUploader.tsx +++ b/components/ImageUploader.tsx @@ -1,216 +1,150 @@ -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import Cropper from "react-easy-crop"; -import Slider from "./Slider"; +import { Area, getCroppedImg } from "@lib/cropImage"; +import { useFileReader } from "@lib/hooks/useFileReader"; -export default function ImageUploader({ target, id, buttonMsg, handleAvatarChange, imageRef }) { - const imageFileRef = useRef(); - const [imageDataUrl, setImageDataUrl] = useState(); - const [croppedAreaPixels, setCroppedAreaPixels] = useState(); - const [rotation] = useState(1); +import { DialogClose, DialogTrigger, Dialog, DialogContent } from "@components/Dialog"; +import Slider from "@components/Slider"; +import Button from "@components/ui/Button"; + +type ImageUploaderProps = { + id: string; + buttonMsg: string; + handleAvatarChange: (imageSrc: string) => void; + imageSrc?: string; + target: string; +}; + +// This is separate to prevent loading the component until file upload +function CropContainer({ + onCropComplete, + imageSrc, +}: { + imageSrc: string; + onCropComplete: (croppedAreaPixels: Area) => void; +}) { const [crop, setCrop] = useState({ x: 0, y: 0 }); const [zoom, setZoom] = useState(1); - const [imageLoaded, setImageLoaded] = useState(false); - const [isImageShown, setIsImageShown] = useState(false); - const [shownImage, setShownImage] = useState(); - const [imageUploadModalOpen, setImageUploadModalOpen] = useState(false); - - const openUploaderModal = () => { - imageRef ? (setIsImageShown(true), setShownImage(imageRef)) : setIsImageShown(false); - setImageUploadModalOpen(!imageUploadModalOpen); - }; - - const closeImageUploadModal = () => { - setImageUploadModalOpen(false); - }; - - async function ImageUploadHandler() { - const img = await readFile(imageFileRef.current.files[0]); - setImageDataUrl(img); - CropHandler(); - } - - const readFile = (file) => { - return new Promise((resolve) => { - const reader = new FileReader(); - reader.addEventListener("load", () => resolve(reader.result), false); - reader.readAsDataURL(file); - }); - }; - - const onCropComplete = useCallback((croppedArea, croppedAreaPixels) => { - setCroppedAreaPixels(croppedAreaPixels); - }, []); - - const CropHandler = () => { - setCrop({ x: 0, y: 0 }); - setZoom(1); - setImageLoaded(true); - }; const handleZoomSliderChange = ([value]) => { value < 1 ? setZoom(1) : setZoom(value); }; - const createImage = (url) => - new Promise((resolve, reject) => { - const image = new Image(); - image.addEventListener("load", () => resolve(image)); - image.addEventListener("error", (error) => reject(error)); - image.setAttribute("crossOrigin", "anonymous"); // needed to avoid cross-origin issues on CodeSandbox - image.src = url; - }); - - function getRadianAngle(degreeValue) { - return (degreeValue * Math.PI) / 180; - } - - async function getCroppedImg(imageSrc, pixelCrop, rotation = 0) { - const image = await createImage(imageSrc); - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - - const maxSize = Math.max(image.width, image.height); - const safeArea = 2 * ((maxSize / 2) * Math.sqrt(2)); - - // set each dimensions to double largest dimension to allow for a safe area for the - // image to rotate in without being clipped by canvas context - canvas.width = safeArea; - canvas.height = safeArea; - - // translate canvas context to a central location on image to allow rotating around the center. - ctx.translate(safeArea / 2, safeArea / 2); - ctx.rotate(getRadianAngle(rotation)); - ctx.translate(-safeArea / 2, -safeArea / 2); - - // draw rotated image and store data. - ctx.drawImage(image, safeArea / 2 - image.width * 0.5, safeArea / 2 - image.height * 0.5); - const data = ctx.getImageData(0, 0, safeArea, safeArea); - - // set canvas width to final desired crop size - this will clear existing context - canvas.width = pixelCrop.width; - canvas.height = pixelCrop.height; - - // paste generated rotate image with correct offsets for x,y crop values. - ctx.putImageData( - data, - Math.round(0 - safeArea / 2 + image.width * 0.5 - pixelCrop.x), - Math.round(0 - safeArea / 2 + image.height * 0.5 - pixelCrop.y) - ); - - // As Base64 string - return canvas.toDataURL("image/jpeg"); - } - - const showCroppedImage = useCallback(async () => { - try { - const croppedImage = await getCroppedImg(imageDataUrl, croppedAreaPixels, rotation); - setIsImageShown(true); - setShownImage(croppedImage); - setImageLoaded(false); - handleAvatarChange(croppedImage); - closeImageUploadModal(); - } catch (e) { - console.error(e); - } - }, [croppedAreaPixels, rotation]); - return ( -
- - - {imageUploadModalOpen && ( -
-
- - - - -
-
-
- -
-
-
-
- {!imageLoaded && ( -
- {!isImageShown && ( -

No {target}

- )} - {isImageShown && ( - {target} - )} -
- )} - {imageLoaded && ( -
-
- -
- -
- )} - - -
-
-
- - -
-
-
-
- )} +
+
+ onCropComplete(croppedAreaPixels)} + onZoomChange={setZoom} + /> +
+
); } + +export default function ImageUploader({ + target, + id, + buttonMsg, + handleAvatarChange, + ...props +}: ImageUploaderProps) { + const [imageSrc, setImageSrc] = useState(); + const [croppedAreaPixels, setCroppedAreaPixels] = useState(); + + const [{ result }, setFile] = useFileReader({ + method: "readAsDataURL", + }); + + useEffect(() => { + setImageSrc(props.imageSrc); + }, [props.imageSrc]); + + const onInputFile = (e) => { + setFile(e.target.files[0]); + }; + + const showCroppedImage = useCallback( + async (croppedAreaPixels) => { + try { + const croppedImage = await getCroppedImg( + result as string /* result is always string when using readAsDataUrl */, + croppedAreaPixels + ); + setImageSrc(croppedImage); + handleAvatarChange(croppedImage); + } catch (e) { + console.error(e); + } + }, + [result, handleAvatarChange] + ); + + return ( + !opened && setFile(null) // unset file on close + }> + +
+ +
+
+ +
+
+ +
+
+
+
+ {!result && ( +
+ {!imageSrc &&

No {target}

} + {imageSrc && {target}} +
+ )} + {result && } + +
+
+
+ + + + + + +
+
+
+ ); +} diff --git a/components/team/EditTeam.tsx b/components/team/EditTeam.tsx index bc26f1d5..13cb4afd 100644 --- a/components/team/EditTeam.tsx +++ b/components/team/EditTeam.tsx @@ -201,14 +201,14 @@ export default function EditTeam(props: { team: Team | undefined | null; onClose id="avatar" placeholder="URL" className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm" - defaultValue={imageSrc ? imageSrc : props.team?.logo} + defaultValue={imageSrc ?? props.team?.logo} />

diff --git a/lib/cropImage.ts b/lib/cropImage.ts new file mode 100644 index 00000000..d1ea42f5 --- /dev/null +++ b/lib/cropImage.ts @@ -0,0 +1,56 @@ +const MAX_IMAGE_SIZE = 512; + +export type Area = { + width: number; + height: number; + x: number; + y: number; +}; + +const createImage = (url) => + new Promise((resolve, reject) => { + const image = new Image(); + image.addEventListener("load", () => resolve(image)); + image.addEventListener("error", (error) => reject(error)); + image.setAttribute("crossOrigin", "anonymous"); // needed to avoid cross-origin issues on CodeSandbox + image.src = url; + }); + +export async function getCroppedImg(imageSrc: string, pixelCrop: Area) { + const image = await createImage(imageSrc); + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + + const maxSize = Math.max(image.naturalWidth, image.naturalHeight); + const resizeRatio = MAX_IMAGE_SIZE / maxSize < 1 ? Math.max(MAX_IMAGE_SIZE / maxSize, 0.75) : 1; + // huh, what? - Having this turned off actually improves image quality as otherwise anti-aliasing is applied + // this reduces the quality of the image overall because it anti-aliases the existing, copied image; blur results + ctx.imageSmoothingEnabled = false; + // pixelCrop is always 1:1 - width = height + canvas.width = canvas.height = Math.min(maxSize * resizeRatio, pixelCrop.width); + + ctx.drawImage( + image, + pixelCrop.x, + pixelCrop.y, + pixelCrop.width, + pixelCrop.height, + 0, + 0, + canvas.width, + canvas.height + ); + + // on very low ratios, the quality of the resize becomes awful. For this reason the resizeRatio is limited to 0.75 + if (resizeRatio <= 0.75) { + // With a smaller image, thus improved ratio. Keep doing this until the resizeRatio > 0.75. + return getCroppedImg(canvas.toDataURL("image/jpeg"), { + width: canvas.width, + height: canvas.height, + x: 0, + y: 0, + }); + } + + return canvas.toDataURL("image/jpeg"); +} diff --git a/lib/hooks/useFileReader.ts b/lib/hooks/useFileReader.ts new file mode 100644 index 00000000..f4923490 --- /dev/null +++ b/lib/hooks/useFileReader.ts @@ -0,0 +1,48 @@ +import { useEffect, useState } from "react"; + +type ReadAsMethod = "readAsText" | "readAsDataURL" | "readAsArrayBuffer" | "readAsBinaryString"; + +type UseFileReaderProps = { + method: ReadAsMethod; + onLoad?: (result: unknown) => void; +}; + +export const useFileReader = (options: UseFileReaderProps) => { + const { method = "readAsText", onLoad } = options; + const [file, setFile] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState | null>(null); + const [result, setResult] = useState(null); + + useEffect(() => { + if (!file && result) { + setResult(null); + } + }, [file, result]); + + useEffect(() => { + if (!file) { + return; + } + + const reader = new FileReader(); + reader.onloadstart = () => setLoading(true); + reader.onloadend = () => setLoading(false); + reader.onerror = setError; + + reader.onload = (e) => { + setResult(e.target.result); + if (onLoad) { + onLoad(e.target.result); + } + }; + + try { + reader[method](file); + } catch (e) { + setError(e); + } + }, [file, method, onLoad]); + + return [{ result, error, file, loading }, setFile]; +}; diff --git a/lib/teams/getTeam.ts b/lib/teams/getTeam.ts deleted file mode 100644 index d6248be2..00000000 --- a/lib/teams/getTeam.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Team } from "@prisma/client"; - -import prisma from "@lib/prisma"; -import { defaultAvatarSrc } from "@lib/profile"; - -export const getTeam = async (idOrSlug: string): Promise => { - const teamIdOrSlug = idOrSlug; - const teamSelectInput = { - id: true, - name: true, - slug: true, - members: { - where: { - accepted: true, - }, - select: { - user: { - select: { - id: true, - username: true, - email: true, - name: true, - bio: true, - avatar: true, - theme: true, - }, - }, - }, - }, - }; - - const team = await prisma.team.findFirst({ - where: { - OR: [ - { - id: parseInt(teamIdOrSlug) || undefined, - }, - { - slug: teamIdOrSlug, - }, - ], - }, - select: teamSelectInput, - }); - - team.members = team.members.map((member) => { - member.user.avatar = member.user.avatar || defaultAvatarSrc({ email: member.user.email }); - return member; - }); - - return team; -}; diff --git a/pages/settings/profile.tsx b/pages/settings/profile.tsx index ac05a906..a47ff0bb 100644 --- a/pages/settings/profile.tsx +++ b/pages/settings/profile.tsx @@ -236,7 +236,7 @@ export default function Settings(props: Props) { id="avatar-upload" buttonMsg="Change avatar" handleAvatarChange={handleAvatarChange} - imageRef={imageSrc} + imageSrc={imageSrc} />