Can set member photo

This commit is contained in:
Pierre HUBERT 2023-08-10 12:10:09 +02:00
parent 10e8f339cc
commit d1e55d574e
12 changed files with 450 additions and 35 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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;

View File

@ -29,16 +29,31 @@ export class APIClient {
method: "GET" | "POST" | "DELETE" | "PATCH" | "PUT";
allowFail?: boolean;
jsonData?: any;
formData?: FormData;
}): Promise<APIResponse> {
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();

View File

@ -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<void> {
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
*/

View File

@ -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<Area>();
const submit = () => {
p.onSubmit(croppedArea);
};
return (
<Dialog open={true} onClose={p.onCancel}>
<DialogTitle>Rogner l'image</DialogTitle>
<DialogContent>
<div style={{ width: "400px", height: "350px" }}>
<div style={{ height: "320px" }}>
<Cropper
image={p.src}
crop={crop}
zoom={zoom}
aspect={4 / 5}
onCropChange={setCrop}
onCropComplete={(_a, b) => setCroppedArea(b)}
onZoomChange={setZoom}
maxZoom={4}
/>
</div>
<Slider
value={zoom}
step={0.1}
min={1}
max={4}
onChange={(_e, v) => setZoom(Number(v))}
/>
</div>
</DialogContent>
<DialogActions>
{p.processing ? (
<CircularProgress size={"1rem"} />
) : (
<>
<Button onClick={p.onCancel}>Annuler</Button>
<Button onClick={submit} autoFocus>
Envoyer
</Button>
</>
)}
</DialogActions>
</Dialog>
);
}

View File

@ -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 {

View File

@ -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 (
<AsyncWidget
loadKey={memberId}
loadKey={`${memberId}-${count.current}`}
load={load}
ready={member !== undefined}
errMsg="Echec du chargement des informations du membre"
build={() => (
<MemberPage
@ -115,6 +127,7 @@ export function FamilyMemberRoute(): React.ReactElement {
onRequestEdit={() =>
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 (
<div style={{ maxWidth: "2000px", margin: "auto" }}>
<ConfirmLeaveWithoutSaveDialog
@ -287,7 +310,9 @@ export function MemberPage(p: {
)}
</Stack>
</div>
<Grid container spacing={2}>
{/* General info */}
<Grid item sm={12} md={6}>
<PropertiesBox title="Informations générales">
{/* Sex */}
@ -384,20 +409,52 @@ export function MemberPage(p: {
/>
</PropertiesBox>
</Grid>
{/* Contact */}
<Grid item sm={12} md={6}>
<PropertiesBox title="Contact"></PropertiesBox>
<PropertiesBox title="Contact">TODO</PropertiesBox>
</Grid>
{/* Photo */}
<Grid item sm={12} md={6}>
<PropertiesBox title="Photo"></PropertiesBox>
<PropertiesBox title="Photo">
<div style={{ textAlign: "center" }}>
<MemberPhoto member={member} width={150} />
<br />
{p.editing ? (
<p>
Veuillez enregistrer / annuler les modifications apportées à
la fiche avant de changer la photo du membre.
</p>
) : (
<>
<UploadPhotoButton
label={
member.hasPhoto
? "Remplacer la photo"
: "Ajouter une photo"
}
onPhotoSelected={uploadNewPhoto}
/>
</>
)}{" "}
</div>
</PropertiesBox>
</Grid>
{/* Bio */}
<Grid item sm={12} md={6}>
<PropertiesBox title="Biographie"></PropertiesBox>
<PropertiesBox title="Biographie">TODO</PropertiesBox>
</Grid>
{/* Spouse */}
<Grid item sm={12} md={6}>
<PropertiesBox title={member.sex === "F" ? "Époux" : "Épouse"}>
TODO
</PropertiesBox>
</Grid>
{/* Children */}
<Grid item sm={12} md={6}>
<PropertiesBox title="Enfants">TODO</PropertiesBox>
</Grid>

View File

@ -0,0 +1,108 @@
import { Area } from "react-easy-crop";
export function createImage(url: string): Promise<HTMLImageElement> {
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<Blob> {
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");
});
}

View File

@ -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;

View File

@ -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 (
<Avatar
sx={{ width: `${p.width}px`, height: "auto", display: "inline-block" }}
variant="rounded"
src={p.member.photoURL}
/>
);
}

View File

@ -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<void>;
}): React.ReactElement {
const [processing, setProcessing] = React.useState(false);
const [imageBlob, setImageBlob] = React.useState<Blob>();
const [imageURL, setImageURL] = React.useState<string>();
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 */}
<Button onClick={uploadPhoto}>{p.label}</Button>
{/* Crop image dialog */}
{imageURL && (
<ImageCropperDialog
processing={processing}
src={imageURL!}
onCancel={cancelCrop}
onSubmit={submitCrop}
/>
)}
</>
);
}