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": "^18.2.8",
|
||||||
"@types/react-dom": "^18.2.4",
|
"@types/react-dom": "^18.2.4",
|
||||||
"date-and-time": "^3.0.1",
|
"date-and-time": "^3.0.1",
|
||||||
|
"filesize": "^10.0.9",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-easy-crop": "^5.0.0",
|
||||||
"react-router-dom": "^6.11.2",
|
"react-router-dom": "^6.11.2",
|
||||||
"react-scripts": "^5.0.1",
|
"react-scripts": "^5.0.1",
|
||||||
"typescript": "^4.9.5",
|
"typescript": "^4.9.5",
|
||||||
@ -7826,11 +7828,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/filesize": {
|
"node_modules/filesize": {
|
||||||
"version": "8.0.7",
|
"version": "10.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/filesize/-/filesize-8.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/filesize/-/filesize-10.0.9.tgz",
|
||||||
"integrity": "sha512-pjmC+bkIF8XI7fWaH8KxHcZL3DPybs1roSKP4rKDvy20tAWwIObE4+JIseG2byfGKhud5ZnM4YSGKBz7Sh0ndQ==",
|
"integrity": "sha512-BzSxJtyq7ZEBjQPEC6u7GNrK58xwaITCvHPaH7e5145eowrMwLfm5LMu/7PeHTTKxP4joIyNmxCbVJVXv7xPGQ==",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4.0"
|
"node": ">= 10.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fill-range": {
|
"node_modules/fill-range": {
|
||||||
@ -10877,6 +10879,11 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/npm-run-path": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
|
||||||
@ -12907,6 +12914,14 @@
|
|||||||
"node": ">=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": {
|
"node_modules/react-dev-utils/node_modules/loader-utils": {
|
||||||
"version": "3.2.1",
|
"version": "3.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.2.1.tgz",
|
||||||
@ -12927,6 +12942,24 @@
|
|||||||
"react": "^18.2.0"
|
"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": {
|
"node_modules/react-error-overlay": {
|
||||||
"version": "6.0.11",
|
"version": "6.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz",
|
"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": "^18.2.8",
|
||||||
"@types/react-dom": "^18.2.4",
|
"@types/react-dom": "^18.2.4",
|
||||||
"date-and-time": "^3.0.1",
|
"date-and-time": "^3.0.1",
|
||||||
|
"filesize": "^10.0.9",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-easy-crop": "^5.0.0",
|
||||||
"react-router-dom": "^6.11.2",
|
"react-router-dom": "^6.11.2",
|
||||||
"react-scripts": "^5.0.1",
|
"react-scripts": "^5.0.1",
|
||||||
"typescript": "^4.9.5",
|
"typescript": "^4.9.5",
|
||||||
|
@ -2,7 +2,6 @@ import React from "react";
|
|||||||
import {
|
import {
|
||||||
Route,
|
Route,
|
||||||
RouterProvider,
|
RouterProvider,
|
||||||
Routes,
|
|
||||||
createBrowserRouter,
|
createBrowserRouter,
|
||||||
createRoutesFromElements,
|
createRoutesFromElements,
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
@ -18,16 +17,16 @@ import { OIDCCbRoute } from "./routes/auth/OIDCCbRoute";
|
|||||||
import { PasswordForgottenRoute } from "./routes/auth/PasswordForgottenRoute";
|
import { PasswordForgottenRoute } from "./routes/auth/PasswordForgottenRoute";
|
||||||
import { ResetPasswordRoute } from "./routes/auth/ResetPasswordRoute";
|
import { ResetPasswordRoute } from "./routes/auth/ResetPasswordRoute";
|
||||||
import { FamilyHomeRoute } from "./routes/family/FamilyHomeRoute";
|
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 {
|
import {
|
||||||
FamilyCreateMemberRoute,
|
FamilyCreateMemberRoute,
|
||||||
FamilyEditMemberRoute,
|
FamilyEditMemberRoute,
|
||||||
FamilyMemberRoute,
|
FamilyMemberRoute,
|
||||||
} from "./routes/family/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 {
|
interface AuthContext {
|
||||||
signedIn: boolean;
|
signedIn: boolean;
|
||||||
|
@ -29,16 +29,31 @@ export class APIClient {
|
|||||||
method: "GET" | "POST" | "DELETE" | "PATCH" | "PUT";
|
method: "GET" | "POST" | "DELETE" | "PATCH" | "PUT";
|
||||||
allowFail?: boolean;
|
allowFail?: boolean;
|
||||||
jsonData?: any;
|
jsonData?: any;
|
||||||
|
formData?: FormData;
|
||||||
}): Promise<APIResponse> {
|
}): 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, {
|
const res = await fetch(this.backendURL() + args.uri, {
|
||||||
method: args.method,
|
method: args.method,
|
||||||
body: args.jsonData ? JSON.stringify(args.jsonData) : undefined,
|
body: body,
|
||||||
headers: {
|
headers: headers,
|
||||||
"X-auth-token": AuthApi.SignedIn ? AuthApi.AuthToken : "none",
|
|
||||||
"Content-Type": args.jsonData ? "application/json" : "text/plain",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Process response
|
||||||
let data;
|
let data;
|
||||||
if (res.headers.get("content-type") === "application/json")
|
if (res.headers.get("content-type") === "application/json")
|
||||||
data = await res.json();
|
data = await res.json();
|
||||||
|
@ -106,6 +106,14 @@ export class Member implements MemberDataApi {
|
|||||||
? this.last_name ?? ""
|
? this.last_name ?? ""
|
||||||
: `${firstName} ${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 {
|
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
|
* 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 React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import "./index.css";
|
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
import reportWebVitals from "./reportWebVitals";
|
|
||||||
import { ServerApi } from "./api/ServerApi";
|
import { ServerApi } from "./api/ServerApi";
|
||||||
|
import "./index.css";
|
||||||
|
import reportWebVitals from "./reportWebVitals";
|
||||||
|
|
||||||
// Roboto font
|
// Roboto font
|
||||||
import "@fontsource/roboto/300.css";
|
import "@fontsource/roboto/300.css";
|
||||||
import "@fontsource/roboto/400.css";
|
import "@fontsource/roboto/400.css";
|
||||||
import "@fontsource/roboto/500.css";
|
import "@fontsource/roboto/500.css";
|
||||||
import "@fontsource/roboto/700.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 { AlertDialogProvider } from "./hooks/context_providers/AlertDialogProvider";
|
||||||
import { AsyncWidget } from "./widgets/AsyncWidget";
|
import { ConfirmDialogProvider } from "./hooks/context_providers/ConfirmDialogProvider";
|
||||||
import { SnackbarProvider } from "./hooks/context_providers/SnackbarProvider";
|
|
||||||
import { DarkThemeProvider } from "./hooks/context_providers/DarkThemeProvider";
|
import { DarkThemeProvider } from "./hooks/context_providers/DarkThemeProvider";
|
||||||
|
import { SnackbarProvider } from "./hooks/context_providers/SnackbarProvider";
|
||||||
|
import { AsyncWidget } from "./widgets/AsyncWidget";
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
try {
|
try {
|
||||||
|
@ -2,7 +2,7 @@ import ClearIcon from "@mui/icons-material/Clear";
|
|||||||
import DeleteIcon from "@mui/icons-material/Delete";
|
import DeleteIcon from "@mui/icons-material/Delete";
|
||||||
import EditIcon from "@mui/icons-material/Edit";
|
import EditIcon from "@mui/icons-material/Edit";
|
||||||
import SaveIcon from "@mui/icons-material/Save";
|
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 React from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { Member, MemberApi } from "../../api/MemberApi";
|
import { Member, MemberApi } from "../../api/MemberApi";
|
||||||
@ -14,11 +14,13 @@ import { AsyncWidget } from "../../widgets/AsyncWidget";
|
|||||||
import { useFamily } from "../../widgets/BaseFamilyRoute";
|
import { useFamily } from "../../widgets/BaseFamilyRoute";
|
||||||
import { ConfirmLeaveWithoutSaveDialog } from "../../widgets/ConfirmLeaveWithoutSaveDialog";
|
import { ConfirmLeaveWithoutSaveDialog } from "../../widgets/ConfirmLeaveWithoutSaveDialog";
|
||||||
import { FamilyPageTitle } from "../../widgets/FamilyPageTitle";
|
import { FamilyPageTitle } from "../../widgets/FamilyPageTitle";
|
||||||
import { PropEdit } from "../../widgets/forms/PropEdit";
|
|
||||||
import { PropertiesBox } from "../../widgets/PropertiesBox";
|
import { PropertiesBox } from "../../widgets/PropertiesBox";
|
||||||
import { SexSelection } from "../../widgets/forms/SexSelection";
|
|
||||||
import { DateInput } from "../../widgets/forms/DateInput";
|
import { DateInput } from "../../widgets/forms/DateInput";
|
||||||
import { PropCheckbox } from "../../widgets/forms/PropCheckbox";
|
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
|
* Create a new member route
|
||||||
@ -67,6 +69,8 @@ export function FamilyCreateMemberRoute(): React.ReactElement {
|
|||||||
* Get existing member route
|
* Get existing member route
|
||||||
*/
|
*/
|
||||||
export function FamilyMemberRoute(): React.ReactElement {
|
export function FamilyMemberRoute(): React.ReactElement {
|
||||||
|
const count = React.useRef(1);
|
||||||
|
|
||||||
const n = useNavigate();
|
const n = useNavigate();
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
@ -80,6 +84,13 @@ export function FamilyMemberRoute(): React.ReactElement {
|
|||||||
setMember(await MemberApi.GetSingle(family.familyId, Number(memberId)));
|
setMember(await MemberApi.GetSingle(family.familyId, Number(memberId)));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const forceReload = async () => {
|
||||||
|
count.current += 1;
|
||||||
|
setMember(undefined);
|
||||||
|
|
||||||
|
await family.reloadMembersList();
|
||||||
|
};
|
||||||
|
|
||||||
const deleteMember = async () => {
|
const deleteMember = async () => {
|
||||||
try {
|
try {
|
||||||
if (
|
if (
|
||||||
@ -103,8 +114,9 @@ export function FamilyMemberRoute(): React.ReactElement {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AsyncWidget
|
<AsyncWidget
|
||||||
loadKey={memberId}
|
loadKey={`${memberId}-${count.current}`}
|
||||||
load={load}
|
load={load}
|
||||||
|
ready={member !== undefined}
|
||||||
errMsg="Echec du chargement des informations du membre"
|
errMsg="Echec du chargement des informations du membre"
|
||||||
build={() => (
|
build={() => (
|
||||||
<MemberPage
|
<MemberPage
|
||||||
@ -115,6 +127,7 @@ export function FamilyMemberRoute(): React.ReactElement {
|
|||||||
onRequestEdit={() =>
|
onRequestEdit={() =>
|
||||||
n(family.family.URL(`member/${member!.id}/edit`))
|
n(family.family.URL(`member/${member!.id}/edit`))
|
||||||
}
|
}
|
||||||
|
onForceReload={forceReload}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@ -188,15 +201,19 @@ export function MemberPage(p: {
|
|||||||
onSave?: (m: Member) => void;
|
onSave?: (m: Member) => void;
|
||||||
onRequestEdit?: () => void;
|
onRequestEdit?: () => void;
|
||||||
onRequestDelete?: () => void;
|
onRequestDelete?: () => void;
|
||||||
|
onForceReload?: () => void;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
|
const snackbar = useSnackbar();
|
||||||
|
|
||||||
const [changed, setChanged] = React.useState(false);
|
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 = () => {
|
const updatedMember = () => {
|
||||||
setChanged(true);
|
setChanged(true);
|
||||||
setMember(structuredClone(member));
|
setMember(new Member(structuredClone(member)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const save = () => {
|
const save = () => {
|
||||||
@ -215,6 +232,12 @@ export function MemberPage(p: {
|
|||||||
p.onCancel!();
|
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 (
|
return (
|
||||||
<div style={{ maxWidth: "2000px", margin: "auto" }}>
|
<div style={{ maxWidth: "2000px", margin: "auto" }}>
|
||||||
<ConfirmLeaveWithoutSaveDialog
|
<ConfirmLeaveWithoutSaveDialog
|
||||||
@ -287,7 +310,9 @@ export function MemberPage(p: {
|
|||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
|
{/* General info */}
|
||||||
<Grid item sm={12} md={6}>
|
<Grid item sm={12} md={6}>
|
||||||
<PropertiesBox title="Informations générales">
|
<PropertiesBox title="Informations générales">
|
||||||
{/* Sex */}
|
{/* Sex */}
|
||||||
@ -384,20 +409,52 @@ export function MemberPage(p: {
|
|||||||
/>
|
/>
|
||||||
</PropertiesBox>
|
</PropertiesBox>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
{/* Contact */}
|
||||||
<Grid item sm={12} md={6}>
|
<Grid item sm={12} md={6}>
|
||||||
<PropertiesBox title="Contact"></PropertiesBox>
|
<PropertiesBox title="Contact">TODO</PropertiesBox>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
{/* Photo */}
|
||||||
<Grid item sm={12} md={6}>
|
<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>
|
</Grid>
|
||||||
|
|
||||||
|
{/* Bio */}
|
||||||
<Grid item sm={12} md={6}>
|
<Grid item sm={12} md={6}>
|
||||||
<PropertiesBox title="Biographie"></PropertiesBox>
|
<PropertiesBox title="Biographie">TODO</PropertiesBox>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
{/* Spouse */}
|
||||||
<Grid item sm={12} md={6}>
|
<Grid item sm={12} md={6}>
|
||||||
<PropertiesBox title={member.sex === "F" ? "Époux" : "Épouse"}>
|
<PropertiesBox title={member.sex === "F" ? "Époux" : "Épouse"}>
|
||||||
TODO
|
TODO
|
||||||
</PropertiesBox>
|
</PropertiesBox>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
{/* Children */}
|
||||||
<Grid item sm={12} md={6}>
|
<Grid item sm={12} md={6}>
|
||||||
<PropertiesBox title="Enfants">TODO</PropertiesBox>
|
<PropertiesBox title="Enfants">TODO</PropertiesBox>
|
||||||
</Grid>
|
</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,
|
mdiFamilyTree,
|
||||||
mdiHumanMaleFemale,
|
mdiHumanMaleFemale,
|
||||||
mdiLockCheck,
|
mdiLockCheck,
|
||||||
|
mdiRefresh,
|
||||||
} from "@mdi/js";
|
} from "@mdi/js";
|
||||||
import Icon from "@mdi/react";
|
import Icon from "@mdi/react";
|
||||||
import HomeIcon from "@mui/icons-material/Home";
|
import HomeIcon from "@mui/icons-material/Home";
|
||||||
@ -21,16 +22,15 @@ import {
|
|||||||
ListSubheader,
|
ListSubheader,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { mdiRefresh } from "@mdi/js";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Outlet, useLocation, useParams } from "react-router-dom";
|
import { Outlet, useLocation, useParams } from "react-router-dom";
|
||||||
import { Family, FamilyApi } from "../api/FamilyApi";
|
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 { AsyncWidget } from "./AsyncWidget";
|
||||||
import { RouterLink } from "./RouterLink";
|
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 {
|
interface FamilyContext {
|
||||||
family: Family;
|
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