Can set member photo
This commit is contained in:
parent
10e8f339cc
commit
d1e55d574e
41
geneit_app/package-lock.json
generated
41
geneit_app/package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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;
|
||||
|
@ -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();
|
||||
|
@ -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
|
||||
*/
|
||||
|
68
geneit_app/src/dialogs/ImageCropperDialog.tsx
Normal file
68
geneit_app/src/dialogs/ImageCropperDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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 {
|
||||
|
@ -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>
|
||||
|
108
geneit_app/src/utils/crop_image.ts
Normal file
108
geneit_app/src/utils/crop_image.ts
Normal 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");
|
||||
});
|
||||
}
|
@ -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;
|
||||
|
15
geneit_app/src/widgets/MemberPhoto.tsx
Normal file
15
geneit_app/src/widgets/MemberPhoto.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
98
geneit_app/src/widgets/forms/UploadPhotoButton.tsx
Normal file
98
geneit_app/src/widgets/forms/UploadPhotoButton.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
Loading…
Reference in New Issue
Block a user