Can set member photo
This commit is contained in:
		
							
								
								
									
										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}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user