diff --git a/geneit_app/package-lock.json b/geneit_app/package-lock.json index f8a3741..49ec274 100644 --- a/geneit_app/package-lock.json +++ b/geneit_app/package-lock.json @@ -25,8 +25,10 @@ "@types/react": "^18.2.8", "@types/react-dom": "^18.2.4", "date-and-time": "^3.0.1", + "filesize": "^10.0.9", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-easy-crop": "^5.0.0", "react-router-dom": "^6.11.2", "react-scripts": "^5.0.1", "typescript": "^4.9.5", @@ -7826,11 +7828,11 @@ } }, "node_modules/filesize": { - "version": "8.0.7", - "resolved": "https://registry.npmjs.org/filesize/-/filesize-8.0.7.tgz", - "integrity": "sha512-pjmC+bkIF8XI7fWaH8KxHcZL3DPybs1roSKP4rKDvy20tAWwIObE4+JIseG2byfGKhud5ZnM4YSGKBz7Sh0ndQ==", + "version": "10.0.9", + "resolved": "https://registry.npmjs.org/filesize/-/filesize-10.0.9.tgz", + "integrity": "sha512-BzSxJtyq7ZEBjQPEC6u7GNrK58xwaITCvHPaH7e5145eowrMwLfm5LMu/7PeHTTKxP4joIyNmxCbVJVXv7xPGQ==", "engines": { - "node": ">= 0.4.0" + "node": ">= 10.4.0" } }, "node_modules/fill-range": { @@ -10877,6 +10879,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/normalize-wheel": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz", + "integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==" + }, "node_modules/npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", @@ -12907,6 +12914,14 @@ "node": ">=14" } }, + "node_modules/react-dev-utils/node_modules/filesize": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/filesize/-/filesize-8.0.7.tgz", + "integrity": "sha512-pjmC+bkIF8XI7fWaH8KxHcZL3DPybs1roSKP4rKDvy20tAWwIObE4+JIseG2byfGKhud5ZnM4YSGKBz7Sh0ndQ==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/react-dev-utils/node_modules/loader-utils": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.2.1.tgz", @@ -12927,6 +12942,24 @@ "react": "^18.2.0" } }, + "node_modules/react-easy-crop": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-5.0.0.tgz", + "integrity": "sha512-ppYg3E0jxpDW+HdgLa65lCykZSsGMuusBuKD3HeTMs/Aod4xiWyAH5jZn5iHlllLUV2c0PPT6FznvdNeLhO2wA==", + "dependencies": { + "normalize-wheel": "^1.0.1", + "tslib": "2.0.1" + }, + "peerDependencies": { + "react": ">=16.4.0", + "react-dom": ">=16.4.0" + } + }, + "node_modules/react-easy-crop/node_modules/tslib": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.1.tgz", + "integrity": "sha512-SgIkNheinmEBgx1IUNirK0TUD4X9yjjBRTqqjggWCU3pUEqIk3/Uwl3yRixYKT6WjQuGiwDv4NomL3wqRCj+CQ==" + }, "node_modules/react-error-overlay": { "version": "6.0.11", "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz", diff --git a/geneit_app/package.json b/geneit_app/package.json index 2e44c01..7c1f190 100644 --- a/geneit_app/package.json +++ b/geneit_app/package.json @@ -20,8 +20,10 @@ "@types/react": "^18.2.8", "@types/react-dom": "^18.2.4", "date-and-time": "^3.0.1", + "filesize": "^10.0.9", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-easy-crop": "^5.0.0", "react-router-dom": "^6.11.2", "react-scripts": "^5.0.1", "typescript": "^4.9.5", diff --git a/geneit_app/src/App.tsx b/geneit_app/src/App.tsx index 365d4a2..f502a3f 100644 --- a/geneit_app/src/App.tsx +++ b/geneit_app/src/App.tsx @@ -2,7 +2,6 @@ import React from "react"; import { Route, RouterProvider, - Routes, createBrowserRouter, createRoutesFromElements, } from "react-router-dom"; @@ -18,16 +17,16 @@ import { OIDCCbRoute } from "./routes/auth/OIDCCbRoute"; import { PasswordForgottenRoute } from "./routes/auth/PasswordForgottenRoute"; import { ResetPasswordRoute } from "./routes/auth/ResetPasswordRoute"; import { FamilyHomeRoute } from "./routes/family/FamilyHomeRoute"; -import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage"; -import { BaseFamilyRoute } from "./widgets/BaseFamilyRoute"; -import { BaseLoginPage } from "./widgets/BaseLoginpage"; -import { FamilyUsersListRoute } from "./routes/family/FamilyUsersListRoute"; -import { FamilySettingsRoute } from "./routes/family/FamilySettingsRoute"; import { FamilyCreateMemberRoute, FamilyEditMemberRoute, FamilyMemberRoute, } from "./routes/family/FamilyMemberRoute"; +import { FamilySettingsRoute } from "./routes/family/FamilySettingsRoute"; +import { FamilyUsersListRoute } from "./routes/family/FamilyUsersListRoute"; +import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage"; +import { BaseFamilyRoute } from "./widgets/BaseFamilyRoute"; +import { BaseLoginPage } from "./widgets/BaseLoginpage"; interface AuthContext { signedIn: boolean; diff --git a/geneit_app/src/api/ApiClient.ts b/geneit_app/src/api/ApiClient.ts index cc09599..8bfd22c 100644 --- a/geneit_app/src/api/ApiClient.ts +++ b/geneit_app/src/api/ApiClient.ts @@ -29,16 +29,31 @@ export class APIClient { method: "GET" | "POST" | "DELETE" | "PATCH" | "PUT"; allowFail?: boolean; jsonData?: any; + formData?: FormData; }): Promise { + let body = undefined; + let headers: any = { + "X-auth-token": AuthApi.SignedIn ? AuthApi.AuthToken : "none", + }; + + // JSON request + if (args.jsonData) { + headers["Content-Type"] = "application/json"; + body = JSON.stringify(args.jsonData); + } + + // Form data request + else if (args.formData) { + body = args.formData; + } + const res = await fetch(this.backendURL() + args.uri, { method: args.method, - body: args.jsonData ? JSON.stringify(args.jsonData) : undefined, - headers: { - "X-auth-token": AuthApi.SignedIn ? AuthApi.AuthToken : "none", - "Content-Type": args.jsonData ? "application/json" : "text/plain", - }, + body: body, + headers: headers, }); + // Process response let data; if (res.headers.get("content-type") === "application/json") data = await res.json(); diff --git a/geneit_app/src/api/MemberApi.ts b/geneit_app/src/api/MemberApi.ts index 05419b9..e40cf49 100644 --- a/geneit_app/src/api/MemberApi.ts +++ b/geneit_app/src/api/MemberApi.ts @@ -106,6 +106,14 @@ export class Member implements MemberDataApi { ? this.last_name ?? "" : `${firstName} ${this.last_name ?? ""}`; } + + get hasPhoto(): boolean { + return this.photo_id !== null; + } + + get photoURL(): string { + return `${APIClient.backendURL()}/photo/${this.signed_photo_id}`; + } } export class MembersList { @@ -174,6 +182,19 @@ export class MemberApi { }); } + /** + * Set a new photo for a member + */ + static async SetMemberPhoto(m: Member, b: Blob): Promise { + const fd = new FormData(); + fd.append("photo", b); + await APIClient.exec({ + uri: `/family/${m.family_id}/member/${m.id}/photo`, + method: "PUT", + formData: fd, + }); + } + /** * Delete a family member */ diff --git a/geneit_app/src/dialogs/ImageCropperDialog.tsx b/geneit_app/src/dialogs/ImageCropperDialog.tsx new file mode 100644 index 0000000..50f71f0 --- /dev/null +++ b/geneit_app/src/dialogs/ImageCropperDialog.tsx @@ -0,0 +1,68 @@ +import { + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Slider, +} from "@mui/material"; +import React from "react"; +import Cropper, { Area } from "react-easy-crop"; + +export function ImageCropperDialog(p: { + src: string; + onCancel: () => void; + onSubmit: (croppedArea: Area | undefined) => void; + processing: boolean; +}): React.ReactElement { + const [crop, setCrop] = React.useState({ x: 0, y: 0 }); + const [zoom, setZoom] = React.useState(1); + const [croppedArea, setCroppedArea] = React.useState(); + + const submit = () => { + p.onSubmit(croppedArea); + }; + + return ( + + Rogner l'image + +
+
+ setCroppedArea(b)} + onZoomChange={setZoom} + maxZoom={4} + /> +
+ + setZoom(Number(v))} + /> +
+
+ + {p.processing ? ( + + ) : ( + <> + + + + )} + +
+ ); +} diff --git a/geneit_app/src/index.tsx b/geneit_app/src/index.tsx index eeeb293..9692f39 100644 --- a/geneit_app/src/index.tsx +++ b/geneit_app/src/index.tsx @@ -1,21 +1,20 @@ import React from "react"; import ReactDOM from "react-dom/client"; -import "./index.css"; import { App } from "./App"; -import reportWebVitals from "./reportWebVitals"; import { ServerApi } from "./api/ServerApi"; +import "./index.css"; +import reportWebVitals from "./reportWebVitals"; // Roboto font import "@fontsource/roboto/300.css"; import "@fontsource/roboto/400.css"; import "@fontsource/roboto/500.css"; import "@fontsource/roboto/700.css"; -import { BrowserRouter } from "react-router-dom"; -import { ConfirmDialogProvider } from "./hooks/context_providers/ConfirmDialogProvider"; import { AlertDialogProvider } from "./hooks/context_providers/AlertDialogProvider"; -import { AsyncWidget } from "./widgets/AsyncWidget"; -import { SnackbarProvider } from "./hooks/context_providers/SnackbarProvider"; +import { ConfirmDialogProvider } from "./hooks/context_providers/ConfirmDialogProvider"; import { DarkThemeProvider } from "./hooks/context_providers/DarkThemeProvider"; +import { SnackbarProvider } from "./hooks/context_providers/SnackbarProvider"; +import { AsyncWidget } from "./widgets/AsyncWidget"; async function init() { try { diff --git a/geneit_app/src/routes/family/FamilyMemberRoute.tsx b/geneit_app/src/routes/family/FamilyMemberRoute.tsx index e41faf9..72014b0 100644 --- a/geneit_app/src/routes/family/FamilyMemberRoute.tsx +++ b/geneit_app/src/routes/family/FamilyMemberRoute.tsx @@ -2,7 +2,7 @@ import ClearIcon from "@mui/icons-material/Clear"; import DeleteIcon from "@mui/icons-material/Delete"; import EditIcon from "@mui/icons-material/Edit"; import SaveIcon from "@mui/icons-material/Save"; -import { Button, Checkbox, FormControlLabel, Grid, Stack } from "@mui/material"; +import { Button, Grid, Stack } from "@mui/material"; import React from "react"; import { useNavigate, useParams } from "react-router-dom"; import { Member, MemberApi } from "../../api/MemberApi"; @@ -14,11 +14,13 @@ import { AsyncWidget } from "../../widgets/AsyncWidget"; import { useFamily } from "../../widgets/BaseFamilyRoute"; import { ConfirmLeaveWithoutSaveDialog } from "../../widgets/ConfirmLeaveWithoutSaveDialog"; import { FamilyPageTitle } from "../../widgets/FamilyPageTitle"; -import { PropEdit } from "../../widgets/forms/PropEdit"; import { PropertiesBox } from "../../widgets/PropertiesBox"; -import { SexSelection } from "../../widgets/forms/SexSelection"; import { DateInput } from "../../widgets/forms/DateInput"; import { PropCheckbox } from "../../widgets/forms/PropCheckbox"; +import { PropEdit } from "../../widgets/forms/PropEdit"; +import { SexSelection } from "../../widgets/forms/SexSelection"; +import { UploadPhotoButton } from "../../widgets/forms/UploadPhotoButton"; +import { MemberPhoto } from "../../widgets/MemberPhoto"; /** * Create a new member route @@ -67,6 +69,8 @@ export function FamilyCreateMemberRoute(): React.ReactElement { * Get existing member route */ export function FamilyMemberRoute(): React.ReactElement { + const count = React.useRef(1); + const n = useNavigate(); const alert = useAlert(); const confirm = useConfirm(); @@ -80,6 +84,13 @@ export function FamilyMemberRoute(): React.ReactElement { setMember(await MemberApi.GetSingle(family.familyId, Number(memberId))); }; + const forceReload = async () => { + count.current += 1; + setMember(undefined); + + await family.reloadMembersList(); + }; + const deleteMember = async () => { try { if ( @@ -103,8 +114,9 @@ export function FamilyMemberRoute(): React.ReactElement { return ( ( n(family.family.URL(`member/${member!.id}/edit`)) } + onForceReload={forceReload} /> )} /> @@ -188,15 +201,19 @@ export function MemberPage(p: { onSave?: (m: Member) => void; onRequestEdit?: () => void; onRequestDelete?: () => void; + onForceReload?: () => void; }): React.ReactElement { const confirm = useConfirm(); + const snackbar = useSnackbar(); const [changed, setChanged] = React.useState(false); - const [member, setMember] = React.useState(structuredClone(p.member)); + const [member, setMember] = React.useState( + new Member(structuredClone(p.member)) + ); const updatedMember = () => { setChanged(true); - setMember(structuredClone(member)); + setMember(new Member(structuredClone(member))); }; const save = () => { @@ -215,6 +232,12 @@ export function MemberPage(p: { p.onCancel!(); }; + const uploadNewPhoto = async (b: Blob) => { + await MemberApi.SetMemberPhoto(member, b); + snackbar("La photo du membre a été mise à jour avec succès !"); + p.onForceReload?.(); + }; + return (
+ + {/* General info */} {/* Sex */} @@ -384,20 +409,52 @@ export function MemberPage(p: { /> + + {/* Contact */} - + TODO + + {/* Photo */} - + +
+ +
+ {p.editing ? ( +

+ Veuillez enregistrer / annuler les modifications apportées à + la fiche avant de changer la photo du membre. +

+ ) : ( + <> + + + )}{" "} +
+
+ + {/* Bio */} - + TODO + + {/* Spouse */} TODO + + {/* Children */} TODO diff --git a/geneit_app/src/utils/crop_image.ts b/geneit_app/src/utils/crop_image.ts new file mode 100644 index 0000000..551840b --- /dev/null +++ b/geneit_app/src/utils/crop_image.ts @@ -0,0 +1,108 @@ +import { Area } from "react-easy-crop"; + +export function createImage(url: string): Promise { + return 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 function getRadianAngle(degreeValue: number): number { + return (degreeValue * Math.PI) / 180; +} + +/** + * Returns the new bounding area of a rotated rectangle. + */ +export function rotateSize( + width: number, + height: number, + rotation: number +): { width: number; height: number } { + const rotRad = getRadianAngle(rotation); + + return { + width: + Math.abs(Math.cos(rotRad) * width) + Math.abs(Math.sin(rotRad) * height), + height: + Math.abs(Math.sin(rotRad) * width) + Math.abs(Math.cos(rotRad) * height), + }; +} + +/** + * This function was adapted from the one in the ReadMe of https://github.com/DominicTobias/react-image-crop + */ +export default async function getCroppedImg( + imageSrc: string, + pixelCrop: Area, + rotation = 0, + flip = { horizontal: false, vertical: false } +): Promise { + const image = await createImage(imageSrc); + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + + if (!ctx) { + throw new Error("Could not get 2d context!"); + } + + const rotRad = getRadianAngle(rotation); + + // calculate bounding box of the rotated image + const { width: bBoxWidth, height: bBoxHeight } = rotateSize( + image.width, + image.height, + rotation + ); + + // set canvas size to match the bounding box + canvas.width = bBoxWidth; + canvas.height = bBoxHeight; + + // translate canvas context to a central location to allow rotating and flipping around the center + ctx.translate(bBoxWidth / 2, bBoxHeight / 2); + ctx.rotate(rotRad); + ctx.scale(flip.horizontal ? -1 : 1, flip.vertical ? -1 : 1); + ctx.translate(-image.width / 2, -image.height / 2); + + // draw rotated image + ctx.drawImage(image, 0, 0); + + const croppedCanvas = document.createElement("canvas"); + + const croppedCtx = croppedCanvas.getContext("2d"); + + if (!croppedCtx) { + throw new Error("Could not get 2d context!"); + } + + // Set the size of the cropped canvas + croppedCanvas.width = pixelCrop.width; + croppedCanvas.height = pixelCrop.height; + + // Draw the cropped image onto the new canvas + croppedCtx.drawImage( + canvas, + pixelCrop.x, + pixelCrop.y, + pixelCrop.width, + pixelCrop.height, + 0, + 0, + pixelCrop.width, + pixelCrop.height + ); + + // As Base64 string + // return croppedCanvas.toDataURL('image/jpeg'); + + // As a blob + return await new Promise((resolve, _reject) => { + croppedCanvas.toBlob((file) => { + resolve(file!); + }, "image/jpeg"); + }); +} diff --git a/geneit_app/src/widgets/BaseFamilyRoute.tsx b/geneit_app/src/widgets/BaseFamilyRoute.tsx index df1c338..ae14b5c 100644 --- a/geneit_app/src/widgets/BaseFamilyRoute.tsx +++ b/geneit_app/src/widgets/BaseFamilyRoute.tsx @@ -6,6 +6,7 @@ import { mdiFamilyTree, mdiHumanMaleFemale, mdiLockCheck, + mdiRefresh, } from "@mdi/js"; import Icon from "@mdi/react"; import HomeIcon from "@mui/icons-material/Home"; @@ -21,16 +22,15 @@ import { ListSubheader, Tooltip, } from "@mui/material"; -import { mdiRefresh } from "@mdi/js"; import React from "react"; import { Outlet, useLocation, useParams } from "react-router-dom"; import { Family, FamilyApi } from "../api/FamilyApi"; +import { MemberApi, MembersList } from "../api/MemberApi"; +import { useAlert } from "../hooks/context_providers/AlertDialogProvider"; +import { useConfirm } from "../hooks/context_providers/ConfirmDialogProvider"; +import { useSnackbar } from "../hooks/context_providers/SnackbarProvider"; import { AsyncWidget } from "./AsyncWidget"; import { RouterLink } from "./RouterLink"; -import { useSnackbar } from "../hooks/context_providers/SnackbarProvider"; -import { useConfirm } from "../hooks/context_providers/ConfirmDialogProvider"; -import { useAlert } from "../hooks/context_providers/AlertDialogProvider"; -import { Member, MemberApi, MembersList } from "../api/MemberApi"; interface FamilyContext { family: Family; diff --git a/geneit_app/src/widgets/MemberPhoto.tsx b/geneit_app/src/widgets/MemberPhoto.tsx new file mode 100644 index 0000000..2153aad --- /dev/null +++ b/geneit_app/src/widgets/MemberPhoto.tsx @@ -0,0 +1,15 @@ +import { Avatar } from "@mui/material"; +import { Member } from "../api/MemberApi"; + +export function MemberPhoto(p: { + member: Member; + width: number; +}): React.ReactElement { + return ( + + ); +} diff --git a/geneit_app/src/widgets/forms/UploadPhotoButton.tsx b/geneit_app/src/widgets/forms/UploadPhotoButton.tsx new file mode 100644 index 0000000..34a5945 --- /dev/null +++ b/geneit_app/src/widgets/forms/UploadPhotoButton.tsx @@ -0,0 +1,98 @@ +import { Button } from "@mui/material"; +import { filesize } from "filesize"; +import React from "react"; +import { ServerApi } from "../../api/ServerApi"; +import { ImageCropperDialog } from "../../dialogs/ImageCropperDialog"; +import { useAlert } from "../../hooks/context_providers/AlertDialogProvider"; +import { Area } from "react-easy-crop"; +import getCroppedImg from "../../utils/crop_image"; + +export function UploadPhotoButton(p: { + label: string; + onPhotoSelected: (b: Blob) => Promise; +}): React.ReactElement { + const [processing, setProcessing] = React.useState(false); + const [imageBlob, setImageBlob] = React.useState(); + const [imageURL, setImageURL] = React.useState(); + const alert = useAlert(); + + const uploadPhoto = async () => { + try { + // Create file element + const fileEl = document.createElement("input"); + fileEl.type = "file"; + fileEl.accept = + ServerApi.Config.constraints.photo_allowed_types.join(","); + fileEl.click(); + + // Wait for a file to be chosen + await new Promise((res, _rej) => + fileEl.addEventListener("change", () => res(null)) + ); + + if ((fileEl.files?.length ?? 0) === 0) return null; + const file = fileEl.files![0]; + + // Check file size + if (file.size > ServerApi.Config.constraints.photo_max_size) { + await alert( + `Le fichier sélectionné est trop lourd ! (taille maximale acceptée : ${filesize( + ServerApi.Config.constraints.photo_max_size + )})` + ); + return; + } + + const tempURL = URL.createObjectURL(fileEl.files![0]); + + setImageBlob(file); + setImageURL(tempURL); + } catch (e) { + console.error(e); + alert("Failed to upload custom account image!"); + return null; + } + }; + + const cancelCrop = () => { + setImageURL(undefined); + }; + + const submitCrop = async (a: Area | undefined) => { + setProcessing(true); + try { + let blob = imageBlob!; + + if (a) { + blob = await getCroppedImg(imageURL!, a!); + } + + await p.onPhotoSelected(blob); + + setImageBlob(undefined); + setImageURL(undefined); + } catch (e) { + console.error(e); + alert("Echec du traitement de la photo !"); + } + + setProcessing(false); + }; + + return ( + <> + {/* Upload button */} + + + {/* Crop image dialog */} + {imageURL && ( + + )} + + ); +}