From 211c81dd66927f4e1ed43156caf55b799c2e012f Mon Sep 17 00:00:00 2001 From: Pierre HUBERT Date: Mon, 28 Apr 2025 21:08:25 +0200 Subject: [PATCH] Can attach file to movement --- .../src/controllers/server_controller.rs | 8 ++ moneymgr_web/src/api/FileApi.ts | 28 ++++++ moneymgr_web/src/api/ServerApi.ts | 1 + moneymgr_web/src/routes/AccountRoute.tsx | 31 +++++- .../src/widgets/forms/UploadFileButton.tsx | 97 +++++++++++++++++++ 5 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 moneymgr_web/src/api/FileApi.ts create mode 100644 moneymgr_web/src/widgets/forms/UploadFileButton.tsx diff --git a/moneymgr_backend/src/controllers/server_controller.rs b/moneymgr_backend/src/controllers/server_controller.rs index a72984b..a7e886d 100644 --- a/moneymgr_backend/src/controllers/server_controller.rs +++ b/moneymgr_backend/src/controllers/server_controller.rs @@ -42,6 +42,7 @@ pub struct ServerConstraints { pub token_max_inactivity: LenConstraints, pub account_name: LenConstraints, pub movement_label: LenConstraints, + pub file_allowed_types: &'static [&'static str], } impl Default for ServerConstraints { @@ -52,6 +53,13 @@ impl Default for ServerConstraints { token_max_inactivity: LenConstraints::new(3600, 3600 * 24 * 365), account_name: LenConstraints::not_empty(50), movement_label: LenConstraints::not_empty(200), + file_allowed_types: &[ + "image/jpeg", + "image/png", + "image/webp", + "image/gif", + "application/pdf", + ], } } } diff --git a/moneymgr_web/src/api/FileApi.ts b/moneymgr_web/src/api/FileApi.ts new file mode 100644 index 0000000..528d8f5 --- /dev/null +++ b/moneymgr_web/src/api/FileApi.ts @@ -0,0 +1,28 @@ +import { APIClient } from "./ApiClient"; + +export interface UploadedFile { + id: number; + time_create: number; + mime_type: string; + sha512: string; + file_size: number; + file_name: string; + user_id: number; +} + +export class FileApi { + /** + * Upload a new file + */ + static async UploadFile(file: File): Promise { + const fd = new FormData(); + fd.append("file", file); + return ( + await APIClient.exec({ + method: "POST", + uri: "/file", + formData: fd, + }) + ).data; + } +} diff --git a/moneymgr_web/src/api/ServerApi.ts b/moneymgr_web/src/api/ServerApi.ts index 8fc6647..fafceee 100644 --- a/moneymgr_web/src/api/ServerApi.ts +++ b/moneymgr_web/src/api/ServerApi.ts @@ -19,6 +19,7 @@ export interface ServerConstraints { token_max_inactivity: LenConstraint; account_name: LenConstraint; movement_label: LenConstraint; + file_allowed_types: string[]; } export interface LenConstraint { diff --git a/moneymgr_web/src/routes/AccountRoute.tsx b/moneymgr_web/src/routes/AccountRoute.tsx index 54dcb5f..863c7dd 100644 --- a/moneymgr_web/src/routes/AccountRoute.tsx +++ b/moneymgr_web/src/routes/AccountRoute.tsx @@ -23,6 +23,8 @@ import { DateWidget } from "../widgets/DateWidget"; import { MoneyMgrWebRouteContainer } from "../widgets/MoneyMgrWebRouteContainer"; import { NewMovementWidget } from "../widgets/NewMovementWidget"; import { NotFoundRoute } from "./NotFound"; +import { UploadFileButton } from "../widgets/forms/UploadFileButton"; +import { UploadedFile } from "../api/FileApi"; export function AccountRoute(): React.ReactElement { const loadingMessage = useLoadingMessage(); @@ -105,6 +107,21 @@ function MovementsTable(p: { const [rowSelectionModel, setRowSelectionModel] = React.useState([]); + // Set uploaded file + const setUploadedFile = async ( + m: Movement, + file: UploadedFile | undefined + ) => { + try { + await MovementApi.Update({ ...m, file_id: file?.id ?? undefined }); + + p.needReload(false); + } catch (e) { + console.error("Failed to attach file to movement!", e); + alert("Failed to attach uploaded file to movement!"); + } + }; + // Change account of movement const handleMoveClick = async (movement: Movement) => { const targetAccount = await chooseAccount( @@ -283,7 +300,19 @@ function MovementsTable(p: { { field: "file", headerName: "File", - // TODO + editable: false, + width: 150, + renderCell: (params) => { + if (!params.row.file_id) + return ( + setUploadedFile(params.row, f)} + /> + ); + else return <>got file; + }, }, { field: "actions", diff --git a/moneymgr_web/src/widgets/forms/UploadFileButton.tsx b/moneymgr_web/src/widgets/forms/UploadFileButton.tsx new file mode 100644 index 0000000..10fecf8 --- /dev/null +++ b/moneymgr_web/src/widgets/forms/UploadFileButton.tsx @@ -0,0 +1,97 @@ +import { Button, IconButton, Tooltip, Typography } from "@mui/material"; +import UploadFileIcon from "@mui/icons-material/UploadFile"; +import { ServerApi } from "../../api/ServerApi"; +import React from "react"; +import { FileApi, UploadedFile } from "../../api/FileApi"; +import { useAlert } from "../../hooks/context_providers/AlertDialogProvider"; +import { useLoadingMessage } from "../../hooks/context_providers/LoadingMessageProvider"; +import { useSnackbar } from "../../hooks/context_providers/SnackbarProvider"; + +// https://medium.com/@dprincecoder/creating-a-drag-and-drop-file-upload-component-in-react-a-step-by-step-guide-4d93b6cc21e0 + +export function UploadFileButton(p: { + onUploaded: (file: UploadedFile) => void; + label: string; + tooltip: string; +}): React.ReactElement { + const alert = useAlert(); + const loadingMessage = useLoadingMessage(); + const snackbar = useSnackbar(); + + const fileInput = React.useRef(null); + + const [dragActive, setDragActive] = React.useState(false); + + const forceFileSelect = () => fileInput.current?.click(); + + const dragEnter = () => { + setDragActive(true); + }; + const dragLeave = () => { + setDragActive(false); + }; + + const handleUpload = async (file: File[]) => { + if (file.length < 1) return; + + try { + loadingMessage.show("Uploading file..."); + + const result = await FileApi.UploadFile(file[0]); + + snackbar("The file was successfully uploaded!"); + + p.onUploaded(result); + } catch (e) { + console.error("Failed to upload file!", e); + alert(`Failed to upload file! ${e}`); + } finally { + loadingMessage.hide(); + } + }; + + const handleDrop = (ev: React.DragEvent) => { + ev.preventDefault(); + handleUpload([...ev.dataTransfer.files]); + }; + + const handlefileChange = (ev: React.ChangeEvent) => { + ev.preventDefault(); + if ((fileInput.current?.files?.length ?? 0) > 0) { + handleUpload([...fileInput.current?.files!]); + } + }; + + return ( + event.preventDefault()} + onDragEnter={dragEnter} + onDragLeave={dragLeave} + > + + + ); +}