Parcourir la source

ajustes: modal de fotos e videos do chat em avaliações

leonardo il y a 2 mois
Parent
commit
04c352f1ab
3 fichiers modifiés avec 204 ajouts et 12 suppressions
  1. 2 2
      dist/index.html
  2. 160 0
      src/components/ModalMidia.vue
  3. 42 10
      src/views/atendimentos/AtendimentoView.vue

+ 2 - 2
dist/index.html

@@ -6,8 +6,8 @@
     <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
     <link rel="icon" type="image/x-icon" href="/favicon.ico?v=2" />
     <link rel="icon" type="image/x-icon" href="/favicon.ico?v=2" />
     <title>ORÁCULO</title>
     <title>ORÁCULO</title>
-    <script type="module" crossorigin src="/assets/index-DDHY7Q2I.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-d-JmXxBm.css">
+    <script type="module" crossorigin src="/assets/index-rjf6_E6r.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-W09r-C3W.css">
   </head>
   </head>
   <body class="bg-background">
   <body class="bg-background">
     <noscript>
     <noscript>

+ 160 - 0
src/components/ModalMidia.vue

@@ -0,0 +1,160 @@
+<script setup>
+import { onMounted, onUnmounted, ref, watch } from "vue";
+
+const props = defineProps({
+  url: { type: String, required: true },
+  tipo: { type: String, required: true }, // 'imagem' | 'video'
+});
+
+const emit = defineEmits(["close"]);
+
+const zoom = ref(1);
+const panX = ref(0);
+const panY = ref(0);
+const arrastando = ref(false);
+let arrasteInicio = { x: 0, y: 0, panX: 0, panY: 0 };
+
+function resetZoom() {
+  zoom.value = 1;
+  panX.value = 0;
+  panY.value = 0;
+}
+
+watch(() => props.url, resetZoom);
+
+function aplicarZoom(delta) {
+  zoom.value = Math.min(4, Math.max(1, +(zoom.value + delta).toFixed(2)));
+  if (zoom.value === 1) {
+    panX.value = 0;
+    panY.value = 0;
+  }
+}
+
+function aoRolar(e) {
+  e.preventDefault();
+  aplicarZoom(e.deltaY < 0 ? 0.25 : -0.25);
+}
+
+function aoDuploClique() {
+  zoom.value === 1 ? aplicarZoom(1) : resetZoom();
+}
+
+function iniciarArraste(e) {
+  if (zoom.value <= 1) return;
+  arrastando.value = true;
+  arrasteInicio = { x: e.clientX, y: e.clientY, panX: panX.value, panY: panY.value };
+}
+
+function moverArraste(e) {
+  if (!arrastando.value) return;
+  panX.value = arrasteInicio.panX + (e.clientX - arrasteInicio.x);
+  panY.value = arrasteInicio.panY + (e.clientY - arrasteInicio.y);
+}
+
+function pararArraste() {
+  arrastando.value = false;
+}
+
+function aoTeclado(e) {
+  if (e.key === "Escape") emit("close");
+}
+
+onMounted(() => window.addEventListener("keydown", aoTeclado));
+onUnmounted(() => window.removeEventListener("keydown", aoTeclado));
+</script>
+
+<template>
+  <Teleport to="body">
+    <Transition name="modal-midia">
+      <div class="fixed inset-0 z-[9999]">
+        <div class="absolute inset-0 bg-black/90" @click="emit('close')" />
+
+        <button
+          type="button"
+          class="absolute right-4 top-4 z-10 flex h-9 w-9 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20"
+          title="Fechar"
+          @click="emit('close')"
+        >
+          <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
+          </svg>
+        </button>
+
+        <div class="relative flex h-full w-full items-center justify-center p-6" @click.self="emit('close')">
+          <img
+            v-if="tipo === 'imagem'"
+            :src="url"
+            alt="Mídia enviada na conversa"
+            class="max-h-[85vh] max-w-full select-none rounded-lg object-contain"
+            :class="zoom > 1 ? (arrastando ? 'cursor-grabbing' : 'cursor-grab') : 'cursor-zoom-in'"
+            :style="{ transform: `scale(${zoom}) translate(${panX / zoom}px, ${panY / zoom}px)`, transition: arrastando ? 'none' : 'transform 0.15s ease' }"
+            draggable="false"
+            @wheel="aoRolar"
+            @dblclick="aoDuploClique"
+            @mousedown="iniciarArraste"
+            @mousemove="moverArraste"
+            @mouseup="pararArraste"
+            @mouseleave="pararArraste"
+          />
+          <video
+            v-else
+            :src="url"
+            controls
+            autoplay
+            class="max-h-[85vh] max-w-full rounded-lg"
+            @click.stop
+          ></video>
+        </div>
+
+        <div
+          v-if="tipo === 'imagem'"
+          class="absolute bottom-6 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1.5 rounded-full bg-white/10 px-2 py-1.5 backdrop-blur"
+        >
+          <button
+            type="button"
+            class="flex h-8 w-8 items-center justify-center rounded-full text-white hover:bg-white/20 disabled:opacity-30"
+            title="Diminuir zoom"
+            :disabled="zoom <= 1"
+            @click="aplicarZoom(-0.25)"
+          >
+            <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14" />
+            </svg>
+          </button>
+          <span class="w-10 text-center text-xs font-medium text-white">{{ Math.round(zoom * 100) }}%</span>
+          <button
+            type="button"
+            class="flex h-8 w-8 items-center justify-center rounded-full text-white hover:bg-white/20 disabled:opacity-30"
+            title="Aumentar zoom"
+            :disabled="zoom >= 4"
+            @click="aplicarZoom(0.25)"
+          >
+            <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M5 12h14" />
+            </svg>
+          </button>
+          <button
+            v-if="zoom > 1"
+            type="button"
+            class="ml-1 rounded-full px-2.5 py-1 text-xs font-medium text-white hover:bg-white/20"
+            title="Restaurar zoom"
+            @click="resetZoom"
+          >
+            Redefinir
+          </button>
+        </div>
+      </div>
+    </Transition>
+  </Teleport>
+</template>
+
+<style scoped>
+.modal-midia-enter-active,
+.modal-midia-leave-active {
+  transition: opacity 0.2s ease;
+}
+.modal-midia-enter-from,
+.modal-midia-leave-to {
+  opacity: 0;
+}
+</style>

+ 42 - 10
src/views/atendimentos/AtendimentoView.vue

@@ -3,6 +3,7 @@ import { computed, onMounted, ref } from "vue";
 import { useRoute, useRouter } from "vue-router";
 import { useRoute, useRouter } from "vue-router";
 import LayoutSistema from "../../layout/LayoutSistema.vue";
 import LayoutSistema from "../../layout/LayoutSistema.vue";
 import BaseBadge from "../../components/base/BaseBadge.vue";
 import BaseBadge from "../../components/base/BaseBadge.vue";
+import ModalMidia from "../../components/ModalMidia.vue";
 import { obterAtendimento } from "../../api/atendimentos.js";
 import { obterAtendimento } from "../../api/atendimentos.js";
 import { resolvidoLabel, resolvidoVariant, scoreVariant, sentimentoLabel, sentimentoVariant } from "../../utils/avaliacoes.js";
 import { resolvidoLabel, resolvidoVariant, scoreVariant, sentimentoLabel, sentimentoVariant } from "../../utils/avaliacoes.js";
 
 
@@ -12,6 +13,7 @@ const router = useRouter();
 const carregando = ref(true);
 const carregando = ref(true);
 const erro = ref("");
 const erro = ref("");
 const atendimento = ref(null);
 const atendimento = ref(null);
+const mediaViewer = ref(null);
 
 
 const avaliacao = computed(() => atendimento.value?.avaliacao ?? null);
 const avaliacao = computed(() => atendimento.value?.avaliacao ?? null);
 const mensagens = computed(() => atendimento.value?.mensagens ?? []);
 const mensagens = computed(() => atendimento.value?.mensagens ?? []);
@@ -36,6 +38,7 @@ function autorLabel(m) {
 
 
 const tiposAudio = new Set(["mpga", "oga", "mp3", "audio", "ptt"]);
 const tiposAudio = new Set(["mpga", "oga", "mp3", "audio", "ptt"]);
 const tiposImagem = new Set(["jpg", "jpeg", "png", "webp", "gif", "image", "sticker"]);
 const tiposImagem = new Set(["jpg", "jpeg", "png", "webp", "gif", "image", "sticker"]);
+const tiposVideo = new Set(["mp4", "webm", "mov", "3gp", "video", "m4v"]);
 
 
 function ehAudio(m) {
 function ehAudio(m) {
   return Boolean(m.Transcricao) || tiposAudio.has(m.Tipodemidia);
   return Boolean(m.Transcricao) || tiposAudio.has(m.Tipodemidia);
@@ -49,9 +52,15 @@ function ehImagem(m) {
   return /\.(jpe?g|png|webp|gif)$/i.test(m.MidiaUrl ?? "");
   return /\.(jpe?g|png|webp|gif)$/i.test(m.MidiaUrl ?? "");
 }
 }
 
 
-// link genérico "Abrir mídia" só para tipos sem renderização própria (pdf, vídeo, ...)
+function ehVideo(m) {
+  if (ehAudio(m)) return false;
+  if (tiposVideo.has(m.Tipodemidia)) return true;
+  return /\.(mp4|webm|mov|3gp|m4v)$/i.test(m.MidiaUrl ?? "");
+}
+
+// link genérico "Abrir mídia" só para tipos sem renderização própria (pdf, ...)
 function midiaLink(m) {
 function midiaLink(m) {
-  return m.MidiaUrl && !ehImagem(m) && !ehAudio(m) ? m.MidiaUrl : null;
+  return m.MidiaUrl && !ehImagem(m) && !ehVideo(m) && !ehAudio(m) ? m.MidiaUrl : null;
 }
 }
 
 
 // path de armazenamento do arquivo (ex.: /data/aud/xxx.mp3); em áudios antigos sem
 // path de armazenamento do arquivo (ex.: /data/aud/xxx.mp3); em áudios antigos sem
@@ -209,14 +218,30 @@ onMounted(async () => {
                     </div>
                     </div>
                   </template>
                   </template>
                   <template v-else-if="ehImagem(m) && m.MidiaUrl">
                   <template v-else-if="ehImagem(m) && m.MidiaUrl">
-                    <a :href="m.MidiaUrl" target="_blank" rel="noopener noreferrer" title="Abrir imagem em tamanho original">
-                      <img
-                        :src="m.MidiaUrl"
-                        alt="Imagem enviada na conversa"
-                        loading="lazy"
-                        class="mt-1.5 max-h-80 max-w-full rounded-lg object-contain"
-                      />
-                    </a>
+                    <img
+                      :src="m.MidiaUrl"
+                      alt="Imagem enviada na conversa"
+                      loading="lazy"
+                      title="Ver imagem"
+                      class="mt-1.5 max-h-80 max-w-full cursor-pointer rounded-lg object-contain"
+                      @click="mediaViewer = { url: m.MidiaUrl, tipo: 'imagem' }"
+                    />
+                  </template>
+                  <template v-else-if="ehVideo(m) && m.MidiaUrl">
+                    <div
+                      class="relative mt-1.5 inline-block cursor-pointer overflow-hidden rounded-lg"
+                      title="Ver vídeo"
+                      @click="mediaViewer = { url: m.MidiaUrl, tipo: 'video' }"
+                    >
+                      <video :src="m.MidiaUrl" preload="metadata" muted class="max-h-80 max-w-full rounded-lg object-contain"></video>
+                      <div class="absolute inset-0 flex items-center justify-center bg-black/20">
+                        <div class="flex h-11 w-11 items-center justify-center rounded-full bg-white/85 text-gray-900">
+                          <svg class="h-5 w-5 translate-x-0.5" viewBox="0 0 24 24" fill="currentColor">
+                            <path d="M8 5v14l11-7z" />
+                          </svg>
+                        </div>
+                      </div>
+                    </div>
                   </template>
                   </template>
                   <p v-else class="mt-0.5 whitespace-pre-wrap break-words text-sm text-gray-800 dark:text-gray-200">{{ m.Texto }}</p>
                   <p v-else class="mt-0.5 whitespace-pre-wrap break-words text-sm text-gray-800 dark:text-gray-200">{{ m.Texto }}</p>
                   <a
                   <a
@@ -236,5 +261,12 @@ onMounted(async () => {
         </section>
         </section>
       </div>
       </div>
     </div>
     </div>
+
+    <ModalMidia
+      v-if="mediaViewer"
+      :url="mediaViewer.url"
+      :tipo="mediaViewer.tipo"
+      @close="mediaViewer = null"
+    />
   </LayoutSistema>
   </LayoutSistema>
 </template>
 </template>