From 96b2a0c96f5025e92f84cd4632eb0b0dd7579f50 Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Tue, 24 Feb 2026 23:52:59 +0100 Subject: [PATCH] feat: image upload optimization with sharp, thumbnails, and structured error codes (#304) - Add sharp for server-side image processing (WebP conversion + thumbnails) - New shared backend utility for image upload, optimization, and cleanup - Return structured error codes from upload endpoints (IMAGE_TOO_LARGE, INVALID_TYPE, etc.) - Frontend error code mapping with i18n support (EN + DE) - MedicationAvatar tries thumbnail first, falls back to full image - Error display in MedicationsPage, MobileEditModal, and Auth avatar upload Closes #302 --- backend/package-lock.json | 536 ++++++++++++++++++ backend/package.json | 1 + backend/src/routes/auth.ts | 64 +-- backend/src/routes/medications.ts | 51 +- backend/src/utils/image-upload.ts | 80 +++ frontend/src/components/Auth.tsx | 34 +- frontend/src/components/MedicationAvatar.tsx | 29 +- frontend/src/components/MobileEditModal.tsx | 9 +- frontend/src/hooks/useMedications.ts | 30 +- frontend/src/i18n/de.json | 7 + frontend/src/i18n/en.json | 7 + frontend/src/pages/MedicationsPage.tsx | 121 +++- frontend/src/test/components/Auth.test.tsx | 2 +- .../src/test/hooks/useMedications.test.ts | 8 +- frontend/src/utils/image-upload.ts | 30 + 15 files changed, 916 insertions(+), 93 deletions(-) create mode 100644 backend/src/utils/image-upload.ts create mode 100644 frontend/src/utils/image-upload.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 1ffb828..c4647bb 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -23,6 +23,7 @@ "fastify": "^5.7.4", "nodemailer": "^8.0.1", "openid-client": "^6.8.2", + "sharp": "^0.34.5", "zod": "^3.23.8" }, "devDependencies": { @@ -268,6 +269,16 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@epic-web/invariant": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", @@ -1500,6 +1511,471 @@ "glob": "^13.0.0" } }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@isaacs/cliui": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", @@ -4912,6 +5388,59 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sharp/node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5240,6 +5769,13 @@ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "license": "MIT" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", diff --git a/backend/package.json b/backend/package.json index 222bce0..16bc4e2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -32,6 +32,7 @@ "fastify": "^5.7.4", "nodemailer": "^8.0.1", "openid-client": "^6.8.2", + "sharp": "^0.34.5", "zod": "^3.23.8" }, "devDependencies": { diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index ea9240a..b0fb925 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -1,4 +1,5 @@ import { randomBytes } from "node:crypto"; +import { resolve } from "node:path"; import argon2 from "argon2"; import { eq, sql } from "drizzle-orm"; import type { FastifyInstance } from "fastify"; @@ -8,6 +9,12 @@ import { getDataDir } from "../db/db-utils.js"; import { refreshTokens, users } from "../db/schema.js"; import { getAuthState, requireAuth } from "../plugins/auth.js"; import type { AuthUser } from "../types/fastify.js"; +import { + ALLOWED_IMAGE_MIME_TYPES, + removeImageFiles, + streamToBuffer, + writeOptimizedImageSet, +} from "../utils/image-upload.js"; // ============================================================================= // Argon2id Configuration - State of the Art Password Hashing @@ -82,6 +89,8 @@ const updateProfileSchema = z.object({ // Auth Routes // ============================================================================= export async function authRoutes(app: FastifyInstance) { + const IMAGES_DIR = resolve(getDataDir(), "images"); + // Token TTLs const accessTtlMinutes = 15; const refreshTtlDays = 14; @@ -462,36 +471,35 @@ export async function authRoutes(app: FastifyInstance) { const data = await request.file(); if (!data) { - return reply.status(400).send({ error: "No file uploaded" }); + return reply.status(400).send({ error: "No file uploaded", code: "NO_FILE" }); } // Validate file type - const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"]; - if (!allowedTypes.includes(data.mimetype)) { - return reply.status(400).send({ error: "Invalid file type. Allowed: JPEG, PNG, WebP, GIF" }); + if (!ALLOWED_IMAGE_MIME_TYPES.includes(data.mimetype)) { + return reply.status(400).send({ error: "Invalid file type", code: "INVALID_TYPE" }); } - // Generate unique filename - const ext = data.filename.split(".").pop() || "jpg"; - const filename = `avatar_${authUser.id}_${Date.now()}.${ext}`; + let uploadBuffer: Buffer; + try { + uploadBuffer = await streamToBuffer(data.file); + } catch (error) { + if (error instanceof Error && error.message === "IMAGE_TOO_LARGE") { + return reply.status(400).send({ error: "Image too large", code: "IMAGE_TOO_LARGE" }); + } + throw error; + } - // Save file - const fs = await import("node:fs/promises"); - const path = await import("node:path"); - const imagesDir = path.join(getDataDir(), "images"); - await fs.mkdir(imagesDir, { recursive: true }); - - const buffer = await data.toBuffer(); - await fs.writeFile(path.join(imagesDir, filename), buffer); + let filename: string; + try { + ({ filename } = await writeOptimizedImageSet(IMAGES_DIR, `avatar_${authUser.id}`, uploadBuffer)); + } catch { + return reply.status(400).send({ error: "Invalid image", code: "INVALID_IMAGE" }); + } // Delete old avatar if exists const [user] = await db.select().from(users).where(eq(users.id, authUser.id)); if (user?.avatarUrl) { - try { - await fs.unlink(path.join(imagesDir, user.avatarUrl)); - } catch { - // Ignore if file doesn't exist - } + removeImageFiles(IMAGES_DIR, user.avatarUrl); } // Update user @@ -522,13 +530,7 @@ export async function authRoutes(app: FastifyInstance) { } // Delete file - const fs = await import("node:fs/promises"); - const path = await import("node:path"); - try { - await fs.unlink(path.join(getDataDir(), "images", user.avatarUrl)); - } catch { - // Ignore if file doesn't exist - } + removeImageFiles(IMAGES_DIR, user.avatarUrl); // Update user await db.update(users).set({ avatarUrl: null, updatedAt: new Date() }).where(eq(users.id, authUser.id)); @@ -555,13 +557,7 @@ export async function authRoutes(app: FastifyInstance) { // Delete avatar file if exists const [user] = await db.select().from(users).where(eq(users.id, authUser.id)); if (user?.avatarUrl) { - const fs = await import("node:fs/promises"); - const path = await import("node:path"); - try { - await fs.unlink(path.join(getDataDir(), "images", user.avatarUrl)); - } catch { - // Ignore if file doesn't exist - } + removeImageFiles(IMAGES_DIR, user.avatarUrl); } // Delete user - cascade delete handles all related data diff --git a/backend/src/routes/medications.ts b/backend/src/routes/medications.ts index 8d17137..7d62f02 100644 --- a/backend/src/routes/medications.ts +++ b/backend/src/routes/medications.ts @@ -1,6 +1,4 @@ -import { createWriteStream, existsSync, unlinkSync } from "node:fs"; -import { extname, resolve } from "node:path"; -import { pipeline } from "node:stream/promises"; +import { resolve } from "node:path"; import { and, eq, like } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; @@ -10,6 +8,12 @@ import { doseTracking, medications, userSettings } from "../db/schema.js"; import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; import type { AuthUser } from "../types/fastify.js"; +import { + ALLOWED_IMAGE_MIME_TYPES, + removeImageFiles, + streamToBuffer, + writeOptimizedImageSet, +} from "../utils/image-upload.js"; import { type Intake, parseIntakesJson, parseLocalDateTime, parseTakenByJson } from "../utils/scheduler-utils.js"; const IMAGES_DIR = resolve(getDataDir(), "images"); @@ -693,10 +697,7 @@ export async function medicationRoutes(app: FastifyInstance) { .where(and(eq(medications.id, idNum), eq(medications.userId, userId))); if (!existing) return reply.notFound(); - if (existing.imageUrl) { - const imagePath = resolve(IMAGES_DIR, existing.imageUrl); - if (existsSync(imagePath)) unlinkSync(imagePath); - } + if (existing.imageUrl) removeImageFiles(IMAGES_DIR, existing.imageUrl); const deleted = await db .delete(medications) @@ -719,24 +720,31 @@ export async function medicationRoutes(app: FastifyInstance) { if (!existing) return reply.notFound(); const data = await req.file(); - if (!data) return reply.badRequest("No file uploaded"); + if (!data) return reply.status(400).send({ error: "No file uploaded", code: "NO_FILE" }); - const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"]; - if (!allowedTypes.includes(data.mimetype)) { - return reply.badRequest("Invalid file type. Allowed: JPEG, PNG, WebP, GIF"); + if (!ALLOWED_IMAGE_MIME_TYPES.includes(data.mimetype)) { + return reply.status(400).send({ error: "Invalid file type", code: "INVALID_TYPE" }); } - const ext = extname(data.filename) || ".jpg"; - const filename = `med-${idNum}-${Date.now()}${ext}`; - const filepath = resolve(IMAGES_DIR, filename); + let uploadBuffer: Buffer; + try { + uploadBuffer = await streamToBuffer(data.file); + } catch (error) { + if (error instanceof Error && error.message === "IMAGE_TOO_LARGE") { + return reply.status(400).send({ error: "Image too large", code: "IMAGE_TOO_LARGE" }); + } + throw error; + } - await pipeline(data.file, createWriteStream(filepath)); + let filename: string; + try { + ({ filename } = await writeOptimizedImageSet(IMAGES_DIR, `med-${idNum}`, uploadBuffer)); + } catch { + return reply.status(400).send({ error: "Invalid image", code: "INVALID_IMAGE" }); + } // Delete old image if exists - if (existing.imageUrl) { - const oldPath = resolve(IMAGES_DIR, existing.imageUrl); - if (existsSync(oldPath)) unlinkSync(oldPath); - } + if (existing.imageUrl) removeImageFiles(IMAGES_DIR, existing.imageUrl); await db .update(medications) @@ -758,10 +766,7 @@ export async function medicationRoutes(app: FastifyInstance) { .where(and(eq(medications.id, idNum), eq(medications.userId, userId))); if (!existing) return reply.notFound(); - if (existing.imageUrl) { - const filepath = resolve(IMAGES_DIR, existing.imageUrl); - if (existsSync(filepath)) unlinkSync(filepath); - } + if (existing.imageUrl) removeImageFiles(IMAGES_DIR, existing.imageUrl); await db .update(medications) diff --git a/backend/src/utils/image-upload.ts b/backend/src/utils/image-upload.ts new file mode 100644 index 0000000..8d8a4d8 --- /dev/null +++ b/backend/src/utils/image-upload.ts @@ -0,0 +1,80 @@ +import { existsSync, unlinkSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { extname, resolve } from "node:path"; +import sharp from "sharp"; + +export const ALLOWED_IMAGE_MIME_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"]; +export const MAX_IMAGE_UPLOAD_BYTES = 10 * 1024 * 1024; + +export function getThumbFilename(imageFilename: string): string { + const ext = extname(imageFilename); + const base = ext ? imageFilename.slice(0, -ext.length) : imageFilename; + return `${base}-thumb.webp`; +} + +export function removeImageFiles(imagesDir: string, imageFilename: string): void { + const fullPath = resolve(imagesDir, imageFilename); + if (existsSync(fullPath)) unlinkSync(fullPath); + + const thumbFilename = getThumbFilename(imageFilename); + if (thumbFilename !== imageFilename) { + const thumbPath = resolve(imagesDir, thumbFilename); + if (existsSync(thumbPath)) unlinkSync(thumbPath); + } +} + +export async function streamToBuffer(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = []; + let totalSize = 0; + + for await (const chunk of stream) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + totalSize += buffer.length; + if (totalSize > MAX_IMAGE_UPLOAD_BYTES) { + throw new Error("IMAGE_TOO_LARGE"); + } + chunks.push(buffer); + } + + return Buffer.concat(chunks); +} + +export async function writeOptimizedImageSet( + imagesDir: string, + filePrefix: string, + uploadBuffer: Buffer, + options?: { + maxEdgePx?: number; + thumbSizePx?: number; + fullQuality?: number; + thumbQuality?: number; + } +): Promise<{ filename: string; thumbFilename: string }> { + const maxEdgePx = options?.maxEdgePx ?? 1600; + const thumbSizePx = options?.thumbSizePx ?? 96; + const fullQuality = options?.fullQuality ?? 82; + const thumbQuality = options?.thumbQuality ?? 76; + + const filename = `${filePrefix}-${Date.now()}.webp`; + const thumbFilename = getThumbFilename(filename); + + const filepath = resolve(imagesDir, filename); + const thumbFilepath = resolve(imagesDir, thumbFilename); + + const optimizedBuffer = await sharp(uploadBuffer, { failOn: "error" }) + .rotate() + .resize({ width: maxEdgePx, height: maxEdgePx, fit: "inside", withoutEnlargement: true }) + .webp({ quality: fullQuality }) + .toBuffer(); + + const thumbBuffer = await sharp(uploadBuffer, { failOn: "error" }) + .rotate() + .resize({ width: thumbSizePx, height: thumbSizePx, fit: "cover", position: "attention" }) + .webp({ quality: thumbQuality }) + .toBuffer(); + + await writeFile(filepath, optimizedBuffer); + await writeFile(thumbFilepath, thumbBuffer); + + return { filename, thumbFilename }; +} diff --git a/frontend/src/components/Auth.tsx b/frontend/src/components/Auth.tsx index 21fabd6..6d5f4ac 100644 --- a/frontend/src/components/Auth.tsx +++ b/frontend/src/components/Auth.tsx @@ -3,6 +3,7 @@ import { createContext, type ReactNode, useCallback, useContext, useEffect, useR import { useTranslation } from "react-i18next"; import { useEscapeKey } from "../hooks/useEscapeKey"; import { withCorrelation } from "../utils/correlation"; +import { MAX_IMAGE_UPLOAD_BYTES, resolveImageUploadError } from "../utils/image-upload"; import { log } from "../utils/logger"; import { ConfirmModal } from "./ConfirmModal"; import { PasswordInput } from "./PasswordInput"; @@ -275,8 +276,16 @@ export function AuthProvider({ children }: { children: ReactNode }) { }); if (!res.ok) { - const err = await res.json().catch(() => ({ error: "Upload failed" })); - throw new Error(err.error || "Upload failed"); + let code = "UNKNOWN"; + try { + const body = (await res.json()) as { code?: string }; + if (typeof body?.code === "string" && body.code.trim().length > 0) { + code = body.code; + } + } catch { + // No JSON body + } + throw new Error(code); } await refreshUser(); @@ -613,6 +622,7 @@ export function UserProfile({ onClose }: { onClose?: () => void }) { const [confirmPassword, setConfirmPassword] = useState(""); const [error, setError] = useState(""); const [success, setSuccess] = useState(""); + const [avatarError, setAvatarError] = useState(""); const [loading, setLoading] = useState(false); const [avatarLoading, setAvatarLoading] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); @@ -624,14 +634,20 @@ export function UserProfile({ onClose }: { onClose?: () => void }) { async function handleAvatarUpload(e: React.ChangeEvent) { const file = e.target.files?.[0]; if (!file) return; + if (file.size > MAX_IMAGE_UPLOAD_BYTES) { + setAvatarError(t("form.imageUploadErrors.tooLarge")); + if (fileInputRef.current) fileInputRef.current.value = ""; + return; + } setAvatarLoading(true); - setError(""); + setAvatarError(""); try { await uploadAvatar(file); - setSuccess(t("auth.avatarUpdated", "Avatar updated")); + setAvatarError(""); } catch (err) { - setError(err instanceof Error ? err.message : "Upload failed"); + const code = err instanceof Error ? err.message : "UNKNOWN"; + setAvatarError(resolveImageUploadError(code, t)); } finally { setAvatarLoading(false); if (fileInputRef.current) fileInputRef.current.value = ""; @@ -640,12 +656,13 @@ export function UserProfile({ onClose }: { onClose?: () => void }) { async function handleAvatarDelete() { setAvatarLoading(true); - setError(""); + setAvatarError(""); try { await deleteAvatar(); - setSuccess(t("auth.avatarRemoved", "Avatar removed")); + setAvatarError(""); } catch (err) { - setError(err instanceof Error ? err.message : "Delete failed"); + const code = err instanceof Error ? err.message : "UNKNOWN"; + setAvatarError(resolveImageUploadError(code, t)); } finally { setAvatarLoading(false); } @@ -740,6 +757,7 @@ export function UserProfile({ onClose }: { onClose?: () => void }) { {user.username} + {avatarError && {avatarError}}
diff --git a/frontend/src/components/MedicationAvatar.tsx b/frontend/src/components/MedicationAvatar.tsx index c94d9c6..2dd679c 100644 --- a/frontend/src/components/MedicationAvatar.tsx +++ b/frontend/src/components/MedicationAvatar.tsx @@ -2,6 +2,8 @@ // MedicationAvatar Component // ============================================================================= +import { useEffect, useState } from "react"; + export type MedicationAvatarProps = { name: string; imageUrl?: string | null; @@ -9,6 +11,12 @@ export type MedicationAvatarProps = { }; export function MedicationAvatar({ name, imageUrl, size = "sm" }: MedicationAvatarProps) { + const [thumbFailed, setThumbFailed] = useState(false); + + useEffect(() => { + setThumbFailed(false); + }, [imageUrl]); + const initials = name .split(" ") @@ -19,7 +27,26 @@ export function MedicationAvatar({ name, imageUrl, size = "sm" }: MedicationAvat const sizeClass = `med-avatar med-avatar-${size}`; if (imageUrl) { - return {name}; + const normalizedImageUrl = imageUrl.toLowerCase(); + const shouldUseThumbFirst = normalizedImageUrl.endsWith(".webp"); + const extIndex = imageUrl.lastIndexOf("."); + const baseName = extIndex > 0 ? imageUrl.slice(0, extIndex) : imageUrl; + const thumbSrc = `/api/images/${baseName}-thumb.webp`; + const fullSrc = `/api/images/${imageUrl}`; + const resolvedSrc = shouldUseThumbFirst && !thumbFailed ? thumbSrc : fullSrc; + + return ( + {name} { + if (shouldUseThumbFirst && !thumbFailed) setThumbFailed(true); + }} + /> + ); } return
{initials}
; } diff --git a/frontend/src/components/MobileEditModal.tsx b/frontend/src/components/MobileEditModal.tsx index a5a4079..651dad5 100644 --- a/frontend/src/components/MobileEditModal.tsx +++ b/frontend/src/components/MobileEditModal.tsx @@ -59,6 +59,7 @@ export interface MobileEditModalProps { meds: Medication[]; onUploadMedImage: (medId: number, file: File) => Promise; onDeleteMedImage: (medId: number) => Promise; + imageUploadError: string | null; // Actions onClose: () => void; onResetForm: () => void; @@ -105,6 +106,7 @@ export function MobileEditModal({ meds, onUploadMedImage, onDeleteMedImage, + imageUploadError, onClose, _onResetForm, onSaveMedication, @@ -454,9 +456,14 @@ export function MobileEditModal({ e.target.files?.[0] && onUploadMedImage(editingId, e.target.files[0])} + onChange={(e) => { + const file = e.target.files?.[0]; + e.target.value = ""; + if (file) void onUploadMedImage(editingId, file); + }} /> )} + {imageUploadError && {imageUploadError}} )} diff --git a/frontend/src/hooks/useMedications.ts b/frontend/src/hooks/useMedications.ts index b7196ab..6d08408 100644 --- a/frontend/src/hooks/useMedications.ts +++ b/frontend/src/hooks/useMedications.ts @@ -50,13 +50,33 @@ export function useMedications(): UseMedicationsReturn { body: formData, credentials: "include", }); - if (res.ok) { - loadMeds(); + if (!res.ok) { + let code = "UNKNOWN"; + try { + const errorBody = (await res.json()) as { code?: string }; + if (typeof errorBody?.code === "string" && errorBody.code.trim().length > 0) { + code = errorBody.code; + } + } catch { + // Keep fallback code when backend response has no JSON body. + } + throw new Error(code); } - } catch { - // ignore + + loadMeds(); + } catch (error) { + if (error instanceof Error) { + // Network failures (fetch itself throws) produce browser-specific messages. + // Normalise to NETWORK_ERROR code so the UI can map to a translated string. + if (error.message === "Failed to fetch" || error.message.startsWith("NetworkError")) { + throw new Error("NETWORK_ERROR"); + } + throw error; + } + throw new Error("UNKNOWN"); + } finally { + setUploadingImage(false); } - setUploadingImage(false); }, [loadMeds] ); diff --git a/frontend/src/i18n/de.json b/frontend/src/i18n/de.json index cc41cf8..db5ca3a 100644 --- a/frontend/src/i18n/de.json +++ b/frontend/src/i18n/de.json @@ -183,6 +183,13 @@ "notes": "Notizen", "medicationImage": "Medikamentenbild", "removeImage": "Bild entfernen", + "imageUploadErrors": { + "tooLarge": "Das Bild ist zu groß. Die maximale Upload-Größe beträgt 10 MB.", + "invalidType": "Ungültiger Dateityp. Erlaubte Formate: JPEG, PNG, WebP, GIF.", + "invalidImage": "Ungültige oder nicht unterstützte Bilddatei.", + "noFile": "Es wurde keine Datei zum Hochladen ausgewählt.", + "generic": "Bild-Upload fehlgeschlagen. Bitte versuche es erneut." + }, "placeholders": { "commercial": "z.B. Ozempic", "generic": "z.B. Semaglutid (optional)", diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 172fa37..3373d51 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -183,6 +183,13 @@ "notes": "Notes", "medicationImage": "Medication Image", "removeImage": "Remove Image", + "imageUploadErrors": { + "tooLarge": "Image is too large. Maximum upload size is 10 MB.", + "invalidType": "Invalid file type. Allowed formats: JPEG, PNG, WebP, GIF.", + "invalidImage": "Invalid or unsupported image file.", + "noFile": "No file was selected for upload.", + "generic": "Image upload failed. Please try again." + }, "placeholders": { "commercial": "e.g. Ozempic", "generic": "e.g. Semaglutide (optional)", diff --git a/frontend/src/pages/MedicationsPage.tsx b/frontend/src/pages/MedicationsPage.tsx index fbe97bc..8900757 100644 --- a/frontend/src/pages/MedicationsPage.tsx +++ b/frontend/src/pages/MedicationsPage.tsx @@ -20,6 +20,7 @@ import { useMedicationForm, useModalHistory, useUnsavedChangesWarning } from ".. import type { DoseUnit, Medication } from "../types"; import { DOSE_UNITS, FIELD_LIMITS, getPackageSize } from "../types"; import { combineDateAndTime, formatDate, formatDateTime, formatNumber } from "../utils/formatters"; +import { MAX_IMAGE_UPLOAD_BYTES, resolveImageUploadError } from "../utils/image-upload"; import { log } from "../utils/logger"; function userStorageKey(userId: number | undefined, key: string): string { @@ -51,6 +52,7 @@ export function MedicationsPage() { setForm, setOriginalForm, editingId, + setEditingId, formSaved, setFormSaved, formChanged, @@ -138,6 +140,32 @@ export function MedicationsPage() { const [showObsoleteConfirm, setShowObsoleteConfirm] = useState(false); const [obsoleteCandidate, setObsoleteCandidate] = useState(null); const [allMeds, setAllMeds] = useState(meds); + const [imageUploadError, setImageUploadError] = useState(null); + + const handlePendingMedicationImageSelection = useCallback( + (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) return; + if (file.size > MAX_IMAGE_UPLOAD_BYTES) { + setImageUploadError(t("form.imageUploadErrors.tooLarge")); + setPendingImage(null); + setPendingImagePreview(null); + return; + } + + setImageUploadError(null); + setPendingImage(file); + const reader = new FileReader(); + reader.onload = (ev) => setPendingImagePreview(ev.target?.result as string); + reader.readAsDataURL(file); + }, + [t] + ); + + useEffect(() => { + setImageUploadError(null); + }, [editingId]); const [showObsolete, setShowObsolete] = useState(true); const [readOnlyView, setReadOnlyView] = useState(false); const [showReportModal, setShowReportModal] = useState(false); @@ -173,6 +201,42 @@ export function MedicationsPage() { void loadAllMeds(); }, [loadAllMeds]); + const tryUploadMedImage = useCallback( + async (medId: number, file: File) => { + setImageUploadError(null); + if (file.size > MAX_IMAGE_UPLOAD_BYTES) { + setImageUploadError(t("form.imageUploadErrors.tooLarge")); + return false; + } + try { + await uploadMedImage(medId, file); + void loadAllMeds(); + setImageUploadError(null); + return true; + } catch (error) { + const code = error instanceof Error ? error.message : "UNKNOWN"; + setImageUploadError(resolveImageUploadError(code, t)); + return false; + } + }, + [t, uploadMedImage, loadAllMeds] + ); + + const handleUploadMedImage = useCallback( + async (medId: number, file: File) => { + await tryUploadMedImage(medId, file); + }, + [tryUploadMedImage] + ); + + const handleDeleteMedImage = useCallback( + async (medId: number) => { + await deleteMedImage(medId); + void loadAllMeds(); + }, + [deleteMedImage, loadAllMeds] + ); + // Calculate total tablets const totalTablets = useMemo(() => { if (form.packageType === "bottle") { @@ -467,7 +531,19 @@ export function MedicationsPage() { // Upload image if pending (for new medications) if (!editingId && pendingImage && saved.id) { - await uploadMedImage(saved.id, pendingImage); + const uploaded = await tryUploadMedImage(saved.id, pendingImage); + if (!uploaded) { + // Keep user in edit mode so upload error stays visible and retry is immediate. + setEditingId(saved.id); + setFormSaved(true); + setOriginalForm(form); + setPendingImage(null); + setPendingImagePreview(null); + loadMeds(); + void loadAllMeds(); + setSaving(false); + return; + } setPendingImage(null); setPendingImagePreview(null); } @@ -608,6 +684,13 @@ export function MedicationsPage() { return () => document.removeEventListener("keydown", handleEscape); }, [showEditModal, closeEditModal]); + function scrollToTopForDesktopEdit() { + if (window.innerWidth <= 768) return; + window.requestAnimationFrame(() => { + window.scrollTo({ top: 0, behavior: "smooth" }); + }); + } + function handleEditClick(med: Medication) { if (formChanged) { pendingActionRef.current = () => { @@ -615,6 +698,7 @@ export function MedicationsPage() { setReadOnlyView(false); startEdit(med, openEditModal); setViewMode("form"); + scrollToTopForDesktopEdit(); }; setUnsavedConfirmSource(showEditModal ? "mobile-edit" : "desktop-form"); setShowUnsavedConfirm(true); @@ -625,6 +709,7 @@ export function MedicationsPage() { setActiveTab("general"); startEdit(med, openEditModal); setViewMode("form"); + scrollToTopForDesktopEdit(); } function handleViewClick(med: Medication) { @@ -847,8 +932,10 @@ export function MedicationsPage() { {s.usage} {s.usage === 1 ? t("common.pill") : t("common.pills")} ·{" "} {s.every === 1 ? t("common.daily") : t("common.everyNDays", { count: s.every })} ·{" "} {t("form.blisters.from")} {formatDateTime(s.start)} - {"takenBy" in s && s.takenBy && · {s.takenBy}} - {"intakeRemindersEnabled" in s && s.intakeRemindersEnabled && ( + {"takenBy" in s && (s as import("../types").Intake).takenBy && ( + · {(s as import("../types").Intake).takenBy} + )} + {"intakeRemindersEnabled" in s && (s as import("../types").Intake).intakeRemindersEnabled && ( {" "}