Bugfix/image upload fuzzy (#844)
This commit is contained in:
parent
6547ef1e86
commit
e12c879242
6 changed files with 243 additions and 257 deletions
|
@ -1,174 +1,46 @@
|
||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import Cropper from "react-easy-crop";
|
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 }) {
|
import { DialogClose, DialogTrigger, Dialog, DialogContent } from "@components/Dialog";
|
||||||
const imageFileRef = useRef<HTMLInputElement>();
|
import Slider from "@components/Slider";
|
||||||
const [imageDataUrl, setImageDataUrl] = useState<string>();
|
import Button from "@components/ui/Button";
|
||||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState();
|
|
||||||
const [rotation] = useState(1);
|
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 [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||||
const [zoom, setZoom] = useState(1);
|
const [zoom, setZoom] = useState(1);
|
||||||
const [imageLoaded, setImageLoaded] = useState(false);
|
|
||||||
const [isImageShown, setIsImageShown] = useState(false);
|
|
||||||
const [shownImage, setShownImage] = useState<string>();
|
|
||||||
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]) => {
|
const handleZoomSliderChange = ([value]) => {
|
||||||
value < 1 ? setZoom(1) : setZoom(value);
|
value < 1 ? setZoom(1) : setZoom(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const createImage = (url) =>
|
|
||||||
new Promise<HTMLImageElement>((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 (
|
return (
|
||||||
<div className="flex justify-center items-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ml-4 cursor-pointer inline-flex items-center px-4 py-1 border border-gray-300 shadow-sm text-xs font-medium rounded-sm text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500;"
|
|
||||||
onClick={openUploaderModal}>
|
|
||||||
{buttonMsg}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{imageUploadModalOpen && (
|
|
||||||
<div
|
|
||||||
className="fixed z-10 inset-0 overflow-y-auto"
|
|
||||||
aria-labelledby="modal-title"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true">
|
|
||||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
|
||||||
aria-hidden="true"></div>
|
|
||||||
|
|
||||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
|
||||||
​
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div className="inline-block align-bottom bg-white rounded-sm px-4 pt-5 pb-4 text-left shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-md sm:w-full sm:p-6">
|
|
||||||
<div className="sm:flex sm:items-start mb-4">
|
|
||||||
<div className="mt-3 text-center sm:mt-0 sm:text-left">
|
|
||||||
<h3 className="font-cal text-lg leading-6 font-bold text-gray-900" id="modal-title">
|
|
||||||
Upload {target}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<div className="cropper mt-6 flex flex-col justify-center items-center p-8 bg-gray-50">
|
|
||||||
{!imageLoaded && (
|
|
||||||
<div className="flex justify-start items-center bg-gray-500 max-h-20 h-20 w-20 rounded-full">
|
|
||||||
{!isImageShown && (
|
|
||||||
<p className="sm:text-xs text-sm text-white w-full text-center">No {target}</p>
|
|
||||||
)}
|
|
||||||
{isImageShown && (
|
|
||||||
<img className="h-20 w-20 rounded-full" src={shownImage} alt={target} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{imageLoaded && (
|
|
||||||
<div className="crop-container max-h-40 h-40 w-40 rounded-full">
|
<div className="crop-container max-h-40 h-40 w-40 rounded-full">
|
||||||
<div className="relative h-40 w-40 rounded-full">
|
<div className="relative h-40 w-40 rounded-full">
|
||||||
<Cropper
|
<Cropper
|
||||||
image={imageDataUrl}
|
image={imageSrc}
|
||||||
crop={crop}
|
crop={crop}
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
aspect={1 / 1}
|
aspect={1}
|
||||||
onCropChange={setCrop}
|
onCropChange={setCrop}
|
||||||
onCropComplete={onCropComplete}
|
onCropComplete={(croppedArea, croppedAreaPixels) => onCropComplete(croppedAreaPixels)}
|
||||||
onZoomChange={setZoom}
|
onZoomChange={setZoom}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -181,36 +53,98 @@ export default function ImageUploader({ target, id, buttonMsg, handleAvatarChang
|
||||||
changeHandler={handleZoomSliderChange}
|
changeHandler={handleZoomSliderChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ImageUploader({
|
||||||
|
target,
|
||||||
|
id,
|
||||||
|
buttonMsg,
|
||||||
|
handleAvatarChange,
|
||||||
|
...props
|
||||||
|
}: ImageUploaderProps) {
|
||||||
|
const [imageSrc, setImageSrc] = useState<string | null>();
|
||||||
|
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>();
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Dialog
|
||||||
|
onOpenChange={
|
||||||
|
(opened) => !opened && setFile(null) // unset file on close
|
||||||
|
}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<div className="flex items-center px-3">
|
||||||
|
<Button color="secondary" type="button" className="py-1 text-xs">
|
||||||
|
{buttonMsg}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<div className="sm:flex sm:items-start mb-4">
|
||||||
|
<div className="mt-3 text-center sm:mt-0 sm:text-left">
|
||||||
|
<h3 className="font-cal text-lg leading-6 font-bold text-gray-900" id="modal-title">
|
||||||
|
Upload {target}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="cropper mt-6 flex flex-col justify-center items-center p-8 bg-gray-50">
|
||||||
|
{!result && (
|
||||||
|
<div className="flex justify-start items-center bg-gray-500 max-h-20 h-20 w-20 rounded-full">
|
||||||
|
{!imageSrc && <p className="sm:text-xs text-sm text-white w-full text-center">No {target}</p>}
|
||||||
|
{imageSrc && <img className="h-20 w-20 rounded-full" src={imageSrc} alt={target} />}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<label
|
{result && <CropContainer imageSrc={result} onCropComplete={setCroppedAreaPixels} />}
|
||||||
htmlFor={id}
|
<label className="mt-8 px-3 py-1 text-xs leading-4 font-medium rounded-sm border border-gray-300 text-gray-700 bg-white hover:bg-gray-50 hover:text-gray-900 hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-neutral-900 dark:bg-transparent dark:text-white dark:border-gray-800 dark:hover:bg-gray-900">
|
||||||
className="mt-8 cursor-pointer inline-flex items-center px-4 py-1 border border-gray-300 shadow-sm text-xs font-medium rounded-sm text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500;">
|
|
||||||
Choose a file...
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
onChange={ImageUploadHandler}
|
onInput={onInputFile}
|
||||||
ref={imageFileRef}
|
|
||||||
type="file"
|
type="file"
|
||||||
id={id}
|
|
||||||
name={id}
|
name={id}
|
||||||
placeholder="Upload image"
|
placeholder="Upload image"
|
||||||
className="mt-4 pointer-events-none opacity-0 absolute"
|
className="mt-4 pointer-events-none opacity-0 absolute"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
/>
|
/>
|
||||||
|
Choose a file...
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse gap-x-2">
|
||||||
<button type="button" className="btn btn-primary" onClick={showCroppedImage}>
|
<DialogClose asChild>
|
||||||
Save
|
<Button onClick={() => showCroppedImage(croppedAreaPixels)}>Save</Button>
|
||||||
</button>
|
</DialogClose>
|
||||||
<button onClick={closeImageUploadModal} type="button" className="btn btn-white mr-2">
|
<DialogClose asChild>
|
||||||
Cancel
|
<Button color="secondary">Cancel</Button>
|
||||||
</button>
|
</DialogClose>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -201,14 +201,14 @@ export default function EditTeam(props: { team: Team | undefined | null; onClose
|
||||||
id="avatar"
|
id="avatar"
|
||||||
placeholder="URL"
|
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"
|
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}
|
||||||
/>
|
/>
|
||||||
<ImageUploader
|
<ImageUploader
|
||||||
target="logo"
|
target="logo"
|
||||||
id="logo-upload"
|
id="logo-upload"
|
||||||
buttonMsg={imageSrc !== "" ? "Edit logo" : "Upload a logo"}
|
buttonMsg={imageSrc !== "" ? "Edit logo" : "Upload a logo"}
|
||||||
handleAvatarChange={handleLogoChange}
|
handleAvatarChange={handleLogoChange}
|
||||||
imageRef={imageSrc ? imageSrc : props.team?.logo}
|
imageSrc={imageSrc ?? props.team?.logo}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<hr className="mt-6" />
|
<hr className="mt-6" />
|
||||||
|
|
56
lib/cropImage.ts
Normal file
56
lib/cropImage.ts
Normal file
|
@ -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<HTMLImageElement>((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");
|
||||||
|
}
|
48
lib/hooks/useFileReader.ts
Normal file
48
lib/hooks/useFileReader.ts
Normal file
|
@ -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<File | null>(null);
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
const [error, setError] = useState<ProgressEvent<FileReader> | null>(null);
|
||||||
|
const [result, setResult] = useState<string | ArrayBuffer | null>(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];
|
||||||
|
};
|
|
@ -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<Team | null> => {
|
|
||||||
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;
|
|
||||||
};
|
|
|
@ -236,7 +236,7 @@ export default function Settings(props: Props) {
|
||||||
id="avatar-upload"
|
id="avatar-upload"
|
||||||
buttonMsg="Change avatar"
|
buttonMsg="Change avatar"
|
||||||
handleAvatarChange={handleAvatarChange}
|
handleAvatarChange={handleAvatarChange}
|
||||||
imageRef={imageSrc}
|
imageSrc={imageSrc}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<hr className="mt-6" />
|
<hr className="mt-6" />
|
||||||
|
|
Loading…
Reference in a new issue