Jelajahi Sumber

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

Gustavo Zanatta 4 minggu lalu
induk
melakukan
30a20d976b

+ 2 - 0
src/api/partnerAgreement.js

@@ -80,6 +80,8 @@ export const uploadPartnerLogo = async (id, file) => {
   form.append("logo", file);
   const { data } = await api.post(`/partner-agreement/${id}/logo`, form, {
     headers: { "Content-Type": "multipart/form-data" },
+    // O salvamento dos dados já notifica; o logo faz parte da mesma ação.
+    skipSuccessNotify: true,
   });
   return data.payload;
 };

+ 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 = "";
   }
 };
 

+ 31 - 12
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,7 +72,7 @@
       <q-file
         v-show="false"
         ref="fileInputRef"
-        v-model="internalFile"
+        v-model="pickedFile"
         :accept="accept"
         :multiple="multiple"
       />
@@ -81,6 +82,7 @@
 
 <script setup>
 import { ref, watch, computed, onUnmounted, useTemplateRef, useAttrs } from "vue";
+import { useMediaFile } from "src/composables/useMediaFile";
 
 defineOptions({
   inheritAttrs: false,
@@ -125,12 +127,15 @@ 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"));
@@ -187,12 +192,19 @@ function handleDrop(event) {
   const dropped = Array.from(event.dataTransfer?.files ?? []).filter(matchesAccept);
   if (!dropped.length) return;
 
-  if (props.multiple) {
-    emit("files", dropped);
+  pickedFile.value = props.multiple ? dropped : dropped[0];
+}
+
+async function acceptFile(file) {
+  if (!(file instanceof File)) {
+    sizeError.value = null;
+    internalFile.value = null;
     return;
   }
 
-  internalFile.value = dropped[0];
+  const { file: prepared, message } = await prepareFile(file);
+  sizeError.value = message;
+  internalFile.value = prepared;
 }
 
 function generateBase64(file) {
@@ -203,16 +215,22 @@ function generateBase64(file) {
   reader.readAsDataURL(file);
 }
 
-watch(internalFile, (newFile) => {
+watch(pickedFile, async (picked) => {
+  if (picked === null) return;
+
+  pickedFile.value = null;
+
   if (props.multiple) {
-    const selected = Array.isArray(newFile) ? newFile : [];
-    if (selected.length) {
-      emit("files", selected);
-      internalFile.value = null;
-    }
+    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);
     objectUrl.value = null;
@@ -232,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,
+  };
+}

+ 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 };
+};

+ 7 - 0
src/i18n/locales/en.json

@@ -176,9 +176,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": {
@@ -320,6 +326,7 @@
   "http": {
     "errors": {
       "404": "Page not found",
+      "413": "File too large to upload",
       "failed": "The action failed",
       "no_records_found": "No records found"
     },

+ 7 - 0
src/i18n/locales/es.json

@@ -177,9 +177,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": {
@@ -321,6 +327,7 @@
   "http": {
     "errors": {
       "404": "Página no encontrada",
+      "413": "Archivo demasiado grande para enviar",
       "failed": "La acción falló",
       "no_records_found": "No se encontraron registros"
     },

+ 7 - 0
src/i18n/locales/pt.json

@@ -177,9 +177,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": {
@@ -321,6 +327,7 @@
   "http": {
     "errors": {
       "404": "Página não encontrada",
+      "413": "Arquivo muito grande para envio",
       "failed": "A ação falhou",
       "no_records_found": "Nenhum registro encontrado"
     },

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

@@ -131,6 +131,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 DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
 
@@ -141,6 +142,7 @@ const AddEditDependentDialog = defineAsyncComponent(
 const $q = useQuasar();
 const { t } = useI18n();
 const store = userStore();
+const { prepareFile } = useMediaFile();
 
 const user = ref(null);
 const dependents = ref([]);
@@ -218,8 +220,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);
@@ -230,7 +237,6 @@ const onAvatarSelected = async (event) => {
     $q.notify({ type: "negative", message: t("http.errors.failed") });
   } finally {
     uploadingAvatar.value = false;
-    event.target.value = "";
   }
 };
 

+ 8 - 2
src/pages/company-settings/CompanySettingsPage.vue

@@ -392,6 +392,7 @@ import {
 } from "src/api/companySettings";
 import { getEvents, deleteEvent, reorderEvents } from "src/api/event";
 import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
+import { useMediaFile } from "src/composables/useMediaFile";
 import { useSubmitHandler } from "src/composables/useSubmitHandler";
 
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
@@ -408,6 +409,7 @@ const AddEditEventDialog = defineAsyncComponent(
 
 const $q = useQuasar();
 const { t } = useI18n();
+const { prepareFile } = useMediaFile();
 const heroFormRef = useTemplateRef("heroFormRef");
 const imageInputRef = useTemplateRef("imageInputRef");
 
@@ -475,8 +477,13 @@ const triggerImageUpload = () => {
 };
 
 const onImageSelected = 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;
+
   uploadingImage.value = true;
   try {
     const updated = await uploadCompanyHeroImage(file);
@@ -486,7 +493,6 @@ const onImageSelected = async (event) => {
     $q.notify({ type: "negative", message: t("common.ui.messages.error") });
   } finally {
     uploadingImage.value = false;
-    event.target.value = "";
   }
 };
 

+ 12 - 3
src/pages/login/FirstAccessFormPage.vue

@@ -196,6 +196,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,
@@ -216,6 +217,7 @@ const $q = useQuasar();
 const { t } = useI18n();
 const { inputRules } = useInputRules();
 const { login } = useAuth();
+const { prepareFile } = useMediaFile();
 
 const formRef = useTemplateRef("formRef");
 const photoInputRef = useTemplateRef("photoInputRef");
@@ -281,10 +283,17 @@ watch(
   { deep: true },
 );
 
-const onPhotoSelected = (event) => {
-  const file = event.target.files?.[0];
+const onPhotoSelected = async (event) => {
+  const selected = event.target.files?.[0];
   event.target.value = "";
-  if (!file) return;
+  if (!selected) return;
+
+  const { file, message } = await prepareFile(selected);
+
+  if (!file) {
+    photoError.value = message;
+    return;
+  }
 
   photoFile.value = file;
   photoPreview.value = URL.createObjectURL(file);

+ 11 - 2
src/pages/parceiros-convenios/ParceiroDadosPage.vue

@@ -328,6 +328,7 @@ import { useQuasar } from "quasar";
 import { useI18n } from "vue-i18n";
 import { useInputRules } from "src/composables/useInputRules";
 import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
+import { useMediaFile } from "src/composables/useMediaFile";
 import { useSubmitHandler } from "src/composables/useSubmitHandler";
 import {
   getMyPartnerAgreement,
@@ -352,6 +353,7 @@ const router = useRouter();
 const route  = useRoute();
 const $q     = useQuasar();
 const { t }  = useI18n();
+const { prepareFile } = useMediaFile();
 const { inputRules } = useInputRules();
 
 const loadingPage = ref(true);
@@ -501,8 +503,15 @@ const {
   execute: execHorario,
 } = useSubmitHandler({ formRef: formHorarioRef });
 
-const onMediaFileSelected = async (file) => {
-  if (!file) return;
+const onMediaFileSelected = async (selected) => {
+  if (!selected) return;
+
+  const { file } = await prepareFile(selected);
+  if (!file) {
+    newMediaFile.value = null;
+    return;
+  }
+
   try {
     const media = await uploadMyPartnerMedia(file);
     contractMedia.value.push(media);

+ 6 - 1
src/pages/parceiros-convenios/ParceiroServicoCadastroPage.vue

@@ -161,6 +161,7 @@ import { useRoute, useRouter } from "vue-router";
 import { useQuasar } from "quasar";
 import { useI18n } from "vue-i18n";
 import { useInputRules } from "src/composables/useInputRules";
+import { useMediaFile } from "src/composables/useMediaFile";
 import { useSubmitHandler } from "src/composables/useSubmitHandler";
 import { getCategories } from "src/api/category";
 import {
@@ -179,6 +180,7 @@ const route  = useRoute();
 const router = useRouter();
 const $q     = useQuasar();
 const { t }  = useI18n();
+const { prepareFile } = useMediaFile();
 const { inputRules } = useInputRules();
 
 const partnerId = computed(() => Number(route.params.id));
@@ -260,7 +262,10 @@ const triggerUpload = (idx) => {
 };
 
 const onFileSelected = async (e) => {
-  const file = e.target.files?.[0];
+  const selected = e.target.files?.[0];
+  if (!selected) return;
+
+  const { file } = await prepareFile(selected);
   if (!file) return;
 
   const idx        = currentUploadIdx.value; // null = novo, number = substituir

+ 11 - 6
src/pages/parceiros-convenios/components/CadastroFormPanel.vue

@@ -377,6 +377,7 @@ import { useQuasar } from "quasar";
 import { useI18n } from "vue-i18n";
 import { useInputRules } from "src/composables/useInputRules";
 import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
+import { useMediaFile } from "src/composables/useMediaFile";
 import { useSubmitHandler } from "src/composables/useSubmitHandler";
 import {
   getPartnerAgreement,
@@ -412,6 +413,7 @@ const route  = useRoute();
 const router = useRouter();
 const $q     = useQuasar();
 const { t }  = useI18n();
+const { prepareFile } = useMediaFile();
 const { inputRules } = useInputRules();
 const permission_store = permissionStore();
 
@@ -480,7 +482,6 @@ const saveDados = async () => {
     entity.value   = saved;
     logoFile.value = null;
     populateForms(saved);
-    $q.notify({ type: "positive", message: t("http.success") });
   });
 };
 
@@ -528,7 +529,6 @@ const {
 const saveContato = async () => {
   await execContato(async () => {
     entity.value = await updatePartnerAgreement(entityId.value, updatedContato.value);
-    $q.notify({ type: "positive", message: t("http.success") });
   });
 };
 
@@ -578,7 +578,6 @@ const onCepChange = async (val) => {
 const saveEndereco = async () => {
   await execEndereco(async () => {
     entity.value = await updatePartnerAgreement(entityId.value, updatedEndereco.value);
-    $q.notify({ type: "positive", message: t("http.success") });
   });
 };
 
@@ -599,8 +598,15 @@ const {
   execute: execHorario,
 } = useSubmitHandler({ formRef: formHorarioRef });
 
-const onMediaFileSelected = async (file) => {
-  if (!file || !entityId.value) return;
+const onMediaFileSelected = async (selected) => {
+  if (!selected || !entityId.value) return;
+
+  const { file } = await prepareFile(selected);
+  if (!file) {
+    newMediaFile.value = null;
+    return;
+  }
+
   try {
     const media = await uploadPartnerMedia(entityId.value, file);
     contractMedia.value.push(media);
@@ -627,7 +633,6 @@ const openMedia = (media) => { window.open(media.url, "_blank"); };
 const saveHorario = async () => {
   await execHorario(async () => {
     entity.value = await updatePartnerAgreement(entityId.value, updatedHorario.value);
-    $q.notify({ type: "positive", message: t("http.success") });
   });
 };
 

+ 11 - 2
src/pages/parceiros-convenios/components/ParceiroCadastroContratoPanel.vue

@@ -81,6 +81,7 @@ import { ref, watch, useTemplateRef } from "vue";
 import { useQuasar } from "quasar";
 import { useI18n } from "vue-i18n";
 import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
+import { useMediaFile } from "src/composables/useMediaFile";
 import { useSubmitHandler } from "src/composables/useSubmitHandler";
 import {
   getPartnerAgreementContrato,
@@ -101,6 +102,7 @@ const emit = defineEmits(["partner-saved"]);
 
 const $q = useQuasar();
 const { t } = useI18n();
+const { prepareFile } = useMediaFile();
 
 const formHorarioRef = useTemplateRef("formHorarioRef");
 const mediaFileInputRef = useTemplateRef("mediaFileInputRef");
@@ -147,8 +149,15 @@ const triggerMediaUpload = () => {
   mediaFileInputRef.value?.pickFiles();
 };
 
-const onMediaFileSelected = async (file) => {
-  if (!file) return;
+const onMediaFileSelected = async (selected) => {
+  if (!selected) return;
+
+  const { file } = await prepareFile(selected);
+  if (!file) {
+    newMediaFile.value = null;
+    return;
+  }
+
   try {
     const media = await uploadPartnerMedia(props.partnerId, file);
     contractMedia.value.push(media);

+ 6 - 1
src/pages/parceiros-convenios/components/ServicoFormPanel.vue

@@ -164,6 +164,7 @@ import { ref, computed, onMounted, useTemplateRef } from "vue";
 import { useQuasar } from "quasar";
 import { useI18n } from "vue-i18n";
 import { useInputRules } from "src/composables/useInputRules";
+import { useMediaFile } from "src/composables/useMediaFile";
 import { useSubmitHandler } from "src/composables/useSubmitHandler";
 import { getCategories } from "src/api/category";
 import {
@@ -187,6 +188,7 @@ const emit = defineEmits(["back"]);
 
 const $q    = useQuasar();
 const { t } = useI18n();
+const { prepareFile } = useMediaFile();
 const { inputRules } = useInputRules();
 
 const isEdit = computed(() => !!props.serviceId);
@@ -265,7 +267,10 @@ const triggerUpload = (idx) => {
 };
 
 const onFileSelected = async (e) => {
-  const file = e.target.files?.[0];
+  const selected = e.target.files?.[0];
+  if (!selected) return;
+
+  const { file } = await prepareFile(selected);
   if (!file) return;
 
   const idx        = currentUploadIdx.value;