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
This commit is contained in:
@@ -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<HTMLInputElement>) {
|
||||
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 }) {
|
||||
</div>
|
||||
</div>
|
||||
<span className="profile-username">{user.username}</span>
|
||||
{avatarError && <span className="field-error">{avatarError}</span>}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleUpdate} className="profile-form">
|
||||
|
||||
@@ -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 <img src={`/api/images/${imageUrl}`} alt={name} className={sizeClass} />;
|
||||
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 (
|
||||
<img
|
||||
src={resolvedSrc}
|
||||
alt={name}
|
||||
className={sizeClass}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={() => {
|
||||
if (shouldUseThumbFirst && !thumbFailed) setThumbFailed(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <div className={`${sizeClass} med-avatar-initials`}>{initials}</div>;
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface MobileEditModalProps {
|
||||
meds: Medication[];
|
||||
onUploadMedImage: (medId: number, file: File) => Promise<void>;
|
||||
onDeleteMedImage: (medId: number) => Promise<void>;
|
||||
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({
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => 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 && <span className="field-error">{imageUploadError}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user