Bladeren bron

feat: compressao de imagens + limitacao de tamanho no upload de imagens para 5mb

Gustavo Zanatta 1 week geleden
bovenliggende
commit
9a90aaf17d

+ 9 - 0
src/boot/axios.js

@@ -2,6 +2,7 @@ import { defineBoot } from "#q-app/wrappers";
 import { Cookies, Notify } from "quasar";
 import axios from "axios";
 import { userStore } from "src/stores/user";
+import { i18n } from "src/boot/i18n";
 
 const api = axios.create({
   baseURL: process.env.API_URL + "/api",
@@ -48,6 +49,14 @@ const errorInterceptor = async (error, router) => {
     return Promise.reject(error);
   }
 
+  if (error.response?.status === 413) {
+    Notify.create({
+      message: i18n.global.t("http.errors.413"),
+      type: "negative",
+    });
+    return Promise.reject(error);
+  }
+
   if (
     error.response?.status !== 401 ||
     originalRequest.url.includes("/refresh")

+ 8 - 2
src/components/CompleteProfileDialog.vue

@@ -184,6 +184,7 @@ import { userStore } from "src/stores/user";
 import { getPositions } from "src/api/position";
 import { getSectors } from "src/api/sector";
 import { uploadMyAvatar, updateMyProfile } from "src/api/user";
+import { useMediaFile } from "src/composables/useMediaFile";
 import TermsAcceptCheckbox from "src/components/TermsAcceptCheckbox.vue";
 
 defineEmits([...useDialogPluginComponent.emits]);
@@ -192,6 +193,7 @@ const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
 const $q = useQuasar();
 const { t } = useI18n();
 const store = userStore();
+const { prepareFile } = useMediaFile();
 
 const avatarInputRef = useTemplateRef("avatarInputRef");
 
@@ -228,8 +230,13 @@ const canSave = computed(() => {
 });
 
 const onAvatarSelected = async (event) => {
-  const file = event.target.files?.[0];
+  const selected = event.target.files?.[0];
+  event.target.value = "";
+  if (!selected) return;
+
+  const { file } = await prepareFile(selected);
   if (!file) return;
+
   uploadingPhoto.value = true;
   try {
     const updated = await uploadMyAvatar(file);
@@ -240,7 +247,6 @@ const onAvatarSelected = async (event) => {
     $q.notify({ type: "negative", message: t("http.errors.failed") });
   } finally {
     uploadingPhoto.value = false;
-    event.target.value = "";
   }
 };
 

+ 53 - 11
src/components/defaults/DefaultFilePicker.vue

@@ -19,8 +19,8 @@
       borderless
       hide-bottom-space
       :rules="rules"
-      :error="error"
-      :error-message="errorMessage"
+      :error="error || Boolean(sizeError)"
+      :error-message="sizeError || errorMessage"
       class="image-preview-container"
     >
       <div
@@ -51,6 +51,7 @@
                   ? $t("common.ui.file.click_select_image")
                   : $t("common.ui.file.click_select")
             }}
+            <div>{{ $t("common.ui.file.max_size_hint", { max: maxSizeMB }) }}</div>
           </div>
         </template>
 
@@ -71,8 +72,9 @@
       <q-file
         v-show="false"
         ref="fileInputRef"
-        v-model="internalFile"
+        v-model="pickedFile"
         :accept="accept"
+        :multiple="multiple"
       />
     </q-field>
   </div>
@@ -80,6 +82,7 @@
 
 <script setup>
 import { ref, watch, computed, onUnmounted, useTemplateRef, useAttrs } from "vue";
+import { useMediaFile } from "src/composables/useMediaFile";
 
 defineOptions({
   inheritAttrs: false,
@@ -102,6 +105,10 @@ const props = defineProps({
     type: String,
     default: "image",
   },
+  multiple: {
+    type: Boolean,
+    default: false,
+  },
   initialImage: {
     type: String,
     default: null,
@@ -116,14 +123,19 @@ const props = defineProps({
   },
 });
 
+const emit = defineEmits(["files"]);
+
 const attrs = useAttrs();
 const fileInputRef = useTemplateRef("fileInputRef");
+const { prepareFile, prepareFiles, maxSizeMB } = useMediaFile();
 
 const model = defineModel({ type: [File, String, null], default: null });
 const base64File = defineModel("base64File", { type: String, default: null });
 
 const isDragging = ref(false);
+const pickedFile = ref(null);
 const internalFile = ref(null);
+const sizeError = ref(null);
 const objectUrl = ref(null);
 
 const required = computed(() => props.rules.some((r) => r?.$id === "required"));
@@ -162,23 +174,37 @@ function handleDragLeave() {
   isDragging.value = false;
 }
 
-function handleDrop(event) {
-  event.preventDefault();
-  isDragging.value = false;
-  const file = event.dataTransfer?.files?.[0];
-  if (!file) return;
+function matchesAccept(file) {
   const acceptedMimes = props.accept
     .split(",")
     .map((m) => m.trim())
     .filter(Boolean);
 
-  const matches = acceptedMimes.some((mime) =>
+  return acceptedMimes.some((mime) =>
     mime.endsWith("/*") ? file.type.startsWith(mime.slice(0, -1)) : mime === file.type,
   );
+}
+
+function handleDrop(event) {
+  event.preventDefault();
+  isDragging.value = false;
 
-  if (matches) {
-    internalFile.value = file;
+  const dropped = Array.from(event.dataTransfer?.files ?? []).filter(matchesAccept);
+  if (!dropped.length) return;
+
+  pickedFile.value = props.multiple ? dropped : dropped[0];
+}
+
+async function acceptFile(file) {
+  if (!(file instanceof File)) {
+    sizeError.value = null;
+    internalFile.value = null;
+    return;
   }
+
+  const { file: prepared, message } = await prepareFile(file);
+  sizeError.value = message;
+  internalFile.value = prepared;
 }
 
 function generateBase64(file) {
@@ -189,6 +215,21 @@ function generateBase64(file) {
   reader.readAsDataURL(file);
 }
 
+watch(pickedFile, async (picked) => {
+  if (picked === null) return;
+
+  pickedFile.value = null;
+
+  if (props.multiple) {
+    const selected = Array.isArray(picked) ? picked : [picked];
+    const accepted = await prepareFiles(selected);
+    if (accepted.length) emit("files", accepted);
+    return;
+  }
+
+  await acceptFile(picked);
+});
+
 watch(internalFile, (newFile) => {
   if (objectUrl.value) {
     URL.revokeObjectURL(objectUrl.value);
@@ -209,6 +250,7 @@ watch(internalFile, (newFile) => {
 watch(model, (val) => {
   if (!val && internalFile.value) {
     internalFile.value = null;
+    sizeError.value = null;
   }
 });
 

+ 57 - 0
src/composables/useMediaFile.js

@@ -0,0 +1,57 @@
+import { useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+import {
+  MAX_MEDIA_SIZE_BYTES,
+  MAX_MEDIA_SIZE_MB,
+  prepareMediaFile,
+} from "src/helpers/mediaFile";
+
+export function useMediaFile() {
+  const $q = useQuasar();
+  const { t } = useI18n();
+
+  const buildMessage = (error) =>
+    t(`common.ui.file.errors.${error.code}`, {
+      size: error.size,
+      max: error.max,
+    });
+
+  const prepareFile = async (file, { notify = true } = {}) => {
+    const { file: prepared, error, compressed } = await prepareMediaFile(file);
+
+    if (error) {
+      const message = buildMessage(error);
+      if (notify) {
+        $q.notify({ type: "negative", message, timeout: 8000 });
+      }
+      return { file: null, message };
+    }
+
+    if (compressed && notify) {
+      $q.notify({
+        type: "info",
+        message: t("common.ui.file.compressed", { max: MAX_MEDIA_SIZE_MB }),
+      });
+    }
+
+    return { file: prepared, message: null };
+  };
+
+  const prepareFiles = async (files, options = {}) => {
+    const accepted = [];
+
+    for (const file of Array.from(files ?? [])) {
+      const { file: prepared } = await prepareFile(file, options);
+      if (prepared) accepted.push(prepared);
+    }
+
+    return accepted;
+  };
+
+  return {
+    prepareFile,
+    prepareFiles,
+    maxSizeMB: MAX_MEDIA_SIZE_MB,
+    maxSizeBytes: MAX_MEDIA_SIZE_BYTES,
+  };
+}

+ 0 - 56
src/helpers/compressImage.js

@@ -1,56 +0,0 @@
-
-const compressImage = (file, { maxSize = 1024, quality = 0.8 } = {}) =>
-  new Promise((resolve) => {
-    if (!file?.type?.startsWith("image/")) {
-      resolve(file);
-      return;
-    }
-
-    const objectUrl = URL.createObjectURL(file);
-    const image = new Image();
-
-    const finish = (result) => {
-      URL.revokeObjectURL(objectUrl);
-      resolve(result);
-    };
-
-    image.onerror = () => finish(file);
-
-    image.onload = () => {
-      const scale = Math.min(1, maxSize / Math.max(image.width, image.height));
-      const canvas = document.createElement("canvas");
-      canvas.width = Math.round(image.width * scale);
-      canvas.height = Math.round(image.height * scale);
-
-      const context = canvas.getContext("2d");
-      if (!context) {
-        finish(file);
-        return;
-      }
-
-      context.drawImage(image, 0, 0, canvas.width, canvas.height);
-
-      canvas.toBlob(
-        (blob) => {
-          if (!blob || blob.size >= file.size) {
-            finish(file);
-            return;
-          }
-
-          const name = file.name.replace(/\.[^.]+$/, "") || "photo";
-          finish(
-            new File([blob], `${name}.jpg`, {
-              type: "image/jpeg",
-              lastModified: Date.now(),
-            }),
-          );
-        },
-        "image/jpeg",
-        quality,
-      );
-    };
-
-    image.src = objectUrl;
-  });
-
-export default compressImage;

+ 119 - 0
src/helpers/mediaFile.js

@@ -0,0 +1,119 @@
+export const MAX_MEDIA_SIZE_MB = 5;
+export const MAX_MEDIA_SIZE_BYTES = MAX_MEDIA_SIZE_MB * 1024 * 1024;
+
+const COMPRESSION_STEPS = [
+  { maxDimension: 2560, quality: 0.85 },
+  { maxDimension: 1920, quality: 0.8 },
+  { maxDimension: 1600, quality: 0.7 },
+  { maxDimension: 1280, quality: 0.6 },
+  { maxDimension: 1024, quality: 0.5 },
+];
+
+const toMb = (bytes) => (bytes / 1024 / 1024).toFixed(1);
+
+export const isImageFile = (file) => Boolean(file?.type?.startsWith("image/"));
+
+const loadImage = (file) =>
+  new Promise((resolve) => {
+    const objectUrl = URL.createObjectURL(file);
+    const image = new Image();
+
+    image.onload = () =>
+      resolve({ image, release: () => URL.revokeObjectURL(objectUrl) });
+    image.onerror = () => {
+      URL.revokeObjectURL(objectUrl);
+      resolve(null);
+    };
+
+    image.src = objectUrl;
+  });
+
+const drawToJpeg = (image, file, { maxDimension, quality }) =>
+  new Promise((resolve) => {
+    const scale = Math.min(1, maxDimension / Math.max(image.width, image.height));
+    const canvas = document.createElement("canvas");
+    canvas.width = Math.max(1, Math.round(image.width * scale));
+    canvas.height = Math.max(1, Math.round(image.height * scale));
+
+    const context = canvas.getContext("2d");
+    if (!context) {
+      resolve(null);
+      return;
+    }
+
+    context.fillStyle = "#ffffff";
+    context.fillRect(0, 0, canvas.width, canvas.height);
+    context.drawImage(image, 0, 0, canvas.width, canvas.height);
+
+    canvas.toBlob(
+      (blob) => {
+        if (!blob) {
+          resolve(null);
+          return;
+        }
+
+        const name = file.name.replace(/\.[^.]+$/, "") || "image";
+        resolve(
+          new File([blob], `${name}.jpg`, {
+            type: "image/jpeg",
+            lastModified: Date.now(),
+          }),
+        );
+      },
+      "image/jpeg",
+      quality,
+    );
+  });
+
+export const compressImageToLimit = async (
+  file,
+  maxSizeBytes = MAX_MEDIA_SIZE_BYTES,
+) => {
+  const loaded = await loadImage(file);
+  if (!loaded) return null;
+
+  try {
+    for (const step of COMPRESSION_STEPS) {
+      const compressed = await drawToJpeg(loaded.image, file, step);
+      if (compressed && compressed.size <= maxSizeBytes) return compressed;
+    }
+  } finally {
+    loaded.release();
+  }
+
+  return null;
+};
+
+export const prepareMediaFile = async (
+  file,
+  { maxSizeBytes = MAX_MEDIA_SIZE_BYTES } = {},
+) => {
+  if (!(file instanceof File)) {
+    return { file: null, error: null, compressed: false };
+  }
+
+  if (file.size <= maxSizeBytes) {
+    return { file, error: null, compressed: false };
+  }
+
+  const error = {
+    size: toMb(file.size),
+    max: toMb(maxSizeBytes),
+  };
+
+  if (!isImageFile(file)) {
+    return { file: null, error: { ...error, code: "too_large" }, compressed: false };
+  }
+
+  const compressed = await compressImageToLimit(file, maxSizeBytes);
+
+  if (!compressed) {
+    return {
+      file: null,
+      error: { ...error, code: "image_too_large" },
+      compressed: false,
+    };
+  }
+
+  return { file: compressed, error: null, compressed: true };
+};

+ 9 - 3
src/i18n/locales/en.json

@@ -175,9 +175,15 @@
         "choose": "Choose a file",
         "click_select": "Click to select a file",
         "click_select_image": "Click to select an image",
+        "compressed": "The image was compressed to fit the {max} MB limit.",
         "drag": "Drag",
         "drag_and_drop": "Drag and drop the file here",
         "drag_here": "Drag the file here",
+        "errors": {
+          "image_too_large": "Could not reduce the image below {max} MB ({size} MB). Choose a smaller image.",
+          "too_large": "File is too large ({size} MB). The limit is {max} MB."
+        },
+        "max_size_hint": "Maximum size: {max} MB",
         "selected": "File selected"
       },
       "table": {
@@ -275,7 +281,6 @@
       "whatsapp": "WhatsApp",
       "photo_hint": "Tap the icon to add your photo",
       "photo_required": "The photo is required",
-      "photo_too_large": "Photo is too large ({size} MB). The limit is {max} MB — take another photo or choose a smaller image.",
       "finish": "Finish Registration",
       "success": "Registration completed successfully!",
       "success_login": "Registration completed! Sign in to access the system.",
@@ -323,10 +328,11 @@
   "http": {
     "errors": {
       "404": "Page not found",
+      "413": "File too large to upload",
       "failed": "The action failed",
-      "unexpected": "Could not complete: {detail}",
       "no_connection": "No connection to the server: {detail}",
-      "no_records_found": "No records found"
+      "no_records_found": "No records found",
+      "unexpected": "Could not complete: {detail}"
     },
     "success": "The action was successful"
   },

+ 9 - 3
src/i18n/locales/es.json

@@ -175,9 +175,15 @@
         "choose": "Elegir un archivo",
         "click_select": "Haga clic para seleccionar un archivo",
         "click_select_image": "Haga clic para seleccionar una imagen",
+        "compressed": "La imagen fue comprimida para caber en el límite de {max} MB.",
         "drag": "Arrastrar",
         "drag_and_drop": "Arrastre y suelte el archivo aquí",
         "drag_here": "Arrastre el archivo aquí",
+        "errors": {
+          "image_too_large": "No fue posible reducir la imagen por debajo de {max} MB ({size} MB). Elija una imagen más pequeña.",
+          "too_large": "Archivo demasiado grande ({size} MB). El límite es {max} MB."
+        },
+        "max_size_hint": "Tamaño máximo: {max} MB",
         "selected": "Archivo seleccionado"
       },
       "table": {
@@ -275,7 +281,6 @@
       "whatsapp": "WhatsApp",
       "photo_hint": "Toque el ícono para agregar su foto",
       "photo_required": "La foto es obligatoria",
-      "photo_too_large": "Foto demasiado grande ({size} MB). El límite es {max} MB — tome otra foto o elija una imagen más pequeña.",
       "finish": "Finalizar Registro",
       "success": "¡Registro completado con éxito!",
       "success_login": "¡Registro completado! Inicie sesión para acceder al sistema.",
@@ -323,10 +328,11 @@
   "http": {
     "errors": {
       "404": "Página no encontrada",
+      "413": "Archivo demasiado grande para enviar",
       "failed": "La acción falló",
-      "unexpected": "No fue posible concluir: {detail}",
       "no_connection": "Sin conexión con el servidor: {detail}",
-      "no_records_found": "No se encontraron registros"
+      "no_records_found": "No se encontraron registros",
+      "unexpected": "No fue posible concluir: {detail}"
     },
     "success": "La acción fue exitosa"
   },

+ 9 - 3
src/i18n/locales/pt.json

@@ -175,9 +175,15 @@
         "choose": "Escolha um arquivo",
         "click_select": "Clique para selecionar um arquivo",
         "click_select_image": "Clique para selecionar uma imagem",
+        "compressed": "A imagem foi comprimida para caber no limite de {max} MB.",
         "drag": "Arraste",
         "drag_and_drop": "Arraste e solte o arquivo aqui",
         "drag_here": "Arraste o arquivo aqui",
+        "errors": {
+          "image_too_large": "Não foi possível reduzir a imagem para menos de {max} MB ({size} MB). Escolha uma imagem menor.",
+          "too_large": "Arquivo muito grande ({size} MB). O limite é {max} MB."
+        },
+        "max_size_hint": "Tamanho máximo: {max} MB",
         "selected": "Arquivo selecionado"
       },
       "table": {
@@ -275,7 +281,6 @@
       "whatsapp": "WhatsApp",
       "photo_hint": "Toque no ícone para adicionar sua foto",
       "photo_required": "A foto é obrigatória",
-      "photo_too_large": "Foto muito grande ({size} MB). O limite é {max} MB — tire outra foto ou escolha uma imagem menor.",
       "finish": "Concluir Cadastro",
       "success": "Cadastro concluído com sucesso!",
       "success_login": "Cadastro concluído! Faça login para acessar o sistema.",
@@ -323,10 +328,11 @@
   "http": {
     "errors": {
       "404": "Página não encontrada",
+      "413": "Arquivo muito grande para envio",
       "failed": "A ação falhou",
-      "unexpected": "Não foi possível concluir: {detail}",
       "no_connection": "Sem conexão com o servidor: {detail}",
-      "no_records_found": "Nenhum registro encontrado"
+      "no_records_found": "Nenhum registro encontrado",
+      "unexpected": "Não foi possível concluir: {detail}"
     },
     "success": "A ação foi bem-sucedida"
   },

+ 8 - 2
src/pages/associado/profile/ProfilePage.vue

@@ -152,6 +152,7 @@ import { useI18n } from "vue-i18n";
 import { userStore } from "src/stores/user";
 import { getDependentsByUser } from "src/api/profile";
 import { uploadMyAvatar, deleteMyAvatar } from "src/api/user";
+import { useMediaFile } from "src/composables/useMediaFile";
 import { PRIVACY_POLICY_URL, openUrl } from "src/helpers/links";
 
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
@@ -163,6 +164,7 @@ const AddEditDependentDialog = defineAsyncComponent(
 const $q = useQuasar();
 const { t } = useI18n();
 const store = userStore();
+const { prepareFile } = useMediaFile();
 
 const user = ref(null);
 const dependents = ref([]);
@@ -240,8 +242,13 @@ const triggerAvatarUpload = () => {
 };
 
 const onAvatarSelected = async (event) => {
-  const file = event.target.files?.[0];
+  const selected = event.target.files?.[0];
+  event.target.value = "";
+  if (!selected) return;
+
+  const { file } = await prepareFile(selected);
   if (!file) return;
+
   uploadingAvatar.value = true;
   try {
     const updated = await uploadMyAvatar(file);
@@ -252,7 +259,6 @@ const onAvatarSelected = async (event) => {
     $q.notify({ type: "negative", message: t("http.errors.failed") });
   } finally {
     uploadingAvatar.value = false;
-    event.target.value = "";
   }
 };
 

+ 8 - 16
src/pages/login/FirstAccessFormPage.vue

@@ -188,6 +188,7 @@ import { useI18n } from "vue-i18n";
 import { useRouter } from "vue-router";
 import { useAuth } from "src/composables/useAuth";
 import { useInputRules } from "src/composables/useInputRules";
+import { useMediaFile } from "src/composables/useMediaFile";
 import { useSubmitHandler } from "src/composables/useSubmitHandler";
 import {
   registerFirstAccess,
@@ -196,7 +197,6 @@ import {
 } from "src/api/firstAccess";
 import { getFirstAccessData, clearFirstAccessData } from "./firstAccessStorage";
 import masks from "src/helpers/masks";
-import compressImage from "src/helpers/compressImage";
 
 import Logo from "src/assets/logo_serprati.svg";
 import DefaultInput from "src/components/defaults/DefaultInput.vue";
@@ -204,14 +204,12 @@ import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
 import DefaultPasswordInput from "src/components/defaults/DefaultPasswordInput.vue";
 import TermsAcceptCheckbox from "src/components/TermsAcceptCheckbox.vue";
 
-const MAX_PHOTO_SIZE_MB = 5;
-const MAX_PHOTO_SIZE_BYTES = MAX_PHOTO_SIZE_MB * 1024 * 1024;
-
 const router = useRouter();
 const $q = useQuasar();
 const { t } = useI18n();
 const { inputRules } = useInputRules();
 const { login } = useAuth();
+const { prepareFile } = useMediaFile();
 
 const formRef = useTemplateRef("formRef");
 const photoInputRef = useTemplateRef("photoInputRef");
@@ -316,25 +314,19 @@ watch(
 );
 
 const onPhotoSelected = async (event) => {
-  const file = event.target.files?.[0];
+  const selected = event.target.files?.[0];
   event.target.value = "";
-  if (!file) return;
-
-  const photo = await compressImage(file);
+  if (!selected) return;
 
-  if (photo.size > MAX_PHOTO_SIZE_BYTES) {
-    const message = t("auth.first_access.photo_too_large", {
-      size: (photo.size / 1024 / 1024).toFixed(1),
-      max: MAX_PHOTO_SIZE_MB,
-    });
+  const { file, message } = await prepareFile(selected);
 
+  if (!file) {
     photoError.value = message;
-    $q.notify({ type: "negative", message, timeout: 8000 });
     return;
   }
 
-  photoFile.value = photo;
-  photoPreview.value = URL.createObjectURL(photo);
+  photoFile.value = file;
+  photoPreview.value = URL.createObjectURL(file);
   photoError.value = null;
 };