|
|
@@ -15,6 +15,16 @@
|
|
|
</button>
|
|
|
</div>
|
|
|
|
|
|
+ <!-- Image Modal -->
|
|
|
+ <div v-if="selectedImage" class="fixed inset-0 z-[100] flex items-center justify-center bg-background/90 backdrop-blur-md" @click="selectedImage = null">
|
|
|
+ <div class="relative max-w-[90vw] max-h-[90vh]">
|
|
|
+ <button @click.stop="selectedImage = null" class="absolute -top-12 right-0 p-2 text-foreground hover:text-primary transition-colors bg-card/50 rounded-full backdrop-blur-sm">
|
|
|
+ <X class="w-6 h-6" />
|
|
|
+ </button>
|
|
|
+ <img :src="getImageUrl(selectedImage)" class="max-w-full max-h-[85vh] rounded-xl shadow-2xl object-contain border border-border/50 animate-in zoom-in-95 duration-200" @click.stop />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
<!-- Messages area -->
|
|
|
<div ref="messagesContainer" class="flex-1 overflow-y-auto px-4 py-3 space-y-3 scrollbar-thin">
|
|
|
<div v-if="isLoading" class="flex items-center justify-center h-full">
|
|
|
@@ -28,7 +38,7 @@
|
|
|
|
|
|
<template v-else>
|
|
|
<div
|
|
|
- v-for="msg in messages"
|
|
|
+ v-for="msg in parsedMessages"
|
|
|
:key="msg.id"
|
|
|
class="flex"
|
|
|
:class="msg.is_from_admin ? 'justify-start' : 'justify-end'"
|
|
|
@@ -43,7 +53,10 @@
|
|
|
<ShieldCheck class="w-3 h-3 opacity-70" />
|
|
|
<span class="text-[10px] font-bold uppercase tracking-wider opacity-70">{{ t("chat.admin") }}</span>
|
|
|
</div>
|
|
|
- <p class="whitespace-pre-wrap break-words">{{ msg.message }}</p>
|
|
|
+ <div v-if="msg.imageUrl" class="mt-1 mb-1">
|
|
|
+ <img :src="getImageUrl(msg.imageUrl)" @click="openImageModal(msg.imageUrl)" class="max-w-[150px] max-h-[150px] rounded-lg cursor-pointer hover:opacity-90 transition-opacity shadow-sm border border-border/20 object-cover" />
|
|
|
+ </div>
|
|
|
+ <p v-if="msg.parsedText" class="whitespace-pre-wrap break-words">{{ msg.parsedText }}</p>
|
|
|
<p class="text-[10px] mt-1 opacity-50 text-right">{{ formatTime(msg.created_at) }}</p>
|
|
|
</div>
|
|
|
</div>
|
|
|
@@ -60,6 +73,7 @@
|
|
|
class="flex-1 resize-none bg-background/80 border border-border/50 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/40 transition-all placeholder:text-muted-foreground/50 max-h-[80px] overflow-y-auto"
|
|
|
@keydown.enter.exact.prevent="handleSend"
|
|
|
@input="autoResize($event)"
|
|
|
+ @paste="handlePaste"
|
|
|
ref="textareaRef"
|
|
|
/>
|
|
|
<button
|
|
|
@@ -77,11 +91,12 @@
|
|
|
</template>
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
-import { ref, onMounted, onUnmounted, nextTick, watch } from "vue";
|
|
|
+import { ref, computed, onMounted, onUnmounted, nextTick, watch } from "vue";
|
|
|
import { useI18n } from "vue-i18n";
|
|
|
import { MessageCircle, Send, Loader2, ShieldCheck, X, Eye } from "lucide-vue-next";
|
|
|
-import { getOrderMessages, sendOrderMessage } from "@/lib/api";
|
|
|
+import { getOrderMessages, sendOrderMessage, RESOURCES_BASE_URL } from "@/lib/api";
|
|
|
import { useAuthStore } from "@/stores/auth";
|
|
|
+import { toast } from "vue-sonner";
|
|
|
|
|
|
import { getWsUrl } from "@/lib/utils";
|
|
|
|
|
|
@@ -103,8 +118,27 @@ const wsConnected = ref(false);
|
|
|
const messagesContainer = ref<HTMLElement | null>(null);
|
|
|
const textareaRef = ref<HTMLTextAreaElement | null>(null);
|
|
|
const otherPartyOnline = ref(false);
|
|
|
+const selectedImage = ref<string | null>(null);
|
|
|
let cooldownInterval: ReturnType<typeof setInterval> | null = null;
|
|
|
|
|
|
+const parsedMessages = computed(() => {
|
|
|
+ return messages.value.map(msg => {
|
|
|
+ let text = msg.message;
|
|
|
+ let imageUrl = null;
|
|
|
+ try {
|
|
|
+ const parsed = JSON.parse(msg.message);
|
|
|
+ if (parsed && typeof parsed === 'object') {
|
|
|
+ if (parsed.image_url) imageUrl = parsed.image_url;
|
|
|
+ if (parsed.text !== undefined) text = parsed.text;
|
|
|
+ else if (parsed.image_url) text = ""; // If only image_url is present, don't render the json string as text
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ // Normal string message (fallback)
|
|
|
+ }
|
|
|
+ return { ...msg, parsedText: text, imageUrl };
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
function startCooldown() {
|
|
|
if (authStore.user?.role === 'admin') return;
|
|
|
cooldownLeft.value = 10;
|
|
|
@@ -208,7 +242,7 @@ async function handleSend() {
|
|
|
|
|
|
isSending.value = true;
|
|
|
try {
|
|
|
- // If WS is connected, send through it for "True Chat" experience
|
|
|
+ // If WS is connected and text only, send through it for "True Chat" experience
|
|
|
if (ws && wsConnected.value && ws.readyState === WebSocket.OPEN) {
|
|
|
ws.send(JSON.stringify({ type: "message", text }));
|
|
|
newMessage.value = "";
|
|
|
@@ -217,7 +251,7 @@ async function handleSend() {
|
|
|
}
|
|
|
} else {
|
|
|
// Fallback to API if WS is down
|
|
|
- const response = await sendOrderMessage(props.orderId, text, locale.value);
|
|
|
+ const response = await sendOrderMessage(props.orderId, text, null, locale.value);
|
|
|
newMessage.value = "";
|
|
|
if (textareaRef.value) {
|
|
|
textareaRef.value.style.height = "auto";
|
|
|
@@ -228,7 +262,7 @@ async function handleSend() {
|
|
|
const myRole = authStore.user?.role === 'admin' ? 'admin' : 'user';
|
|
|
messages.value.push({
|
|
|
id: response.id,
|
|
|
- message: text,
|
|
|
+ message: response.message || text,
|
|
|
is_from_admin: myRole === 'admin',
|
|
|
created_at: new Date().toISOString()
|
|
|
});
|
|
|
@@ -245,6 +279,61 @@ async function handleSend() {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+async function handlePaste(e: ClipboardEvent) {
|
|
|
+ const items = e.clipboardData?.items;
|
|
|
+ if (!items) return;
|
|
|
+ for (let i = 0; i < items.length; i++) {
|
|
|
+ if (items[i].type.indexOf("image") !== -1) {
|
|
|
+ const file = items[i].getAsFile();
|
|
|
+ if (file) {
|
|
|
+ e.preventDefault();
|
|
|
+ await uploadImage(file);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function uploadImage(file: File) {
|
|
|
+ if (isSending.value) return;
|
|
|
+ isSending.value = true;
|
|
|
+ try {
|
|
|
+ const response = await sendOrderMessage(props.orderId, "", file, locale.value);
|
|
|
+ // Add message to list only if it hasn't arrived via WS yet
|
|
|
+ if (!messages.value.some(m => m.id == response.id)) {
|
|
|
+ const myRole = authStore.user?.role === 'admin' ? 'admin' : 'user';
|
|
|
+ messages.value.push({
|
|
|
+ id: response.id,
|
|
|
+ message: response.message,
|
|
|
+ is_from_admin: myRole === 'admin',
|
|
|
+ created_at: new Date().toISOString()
|
|
|
+ });
|
|
|
+ scrollToBottom();
|
|
|
+ }
|
|
|
+ startCooldown();
|
|
|
+ } catch (e: any) {
|
|
|
+ console.error("Failed to upload image:", e);
|
|
|
+ toast.error(t("errors.upload_failed") || "Failed to upload image");
|
|
|
+ } finally {
|
|
|
+ isSending.value = false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function getImageUrl(url: string) {
|
|
|
+ if (!url) return '';
|
|
|
+ if (url.startsWith('http')) return url;
|
|
|
+ return `${RESOURCES_BASE_URL}/${url}`;
|
|
|
+}
|
|
|
+
|
|
|
+function openImageModal(url: string) {
|
|
|
+ selectedImage.value = url;
|
|
|
+}
|
|
|
+
|
|
|
+function handleKeydown(e: KeyboardEvent) {
|
|
|
+ if (e.key === 'Escape' && selectedImage.value) {
|
|
|
+ selectedImage.value = null;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
function scrollToBottom() {
|
|
|
nextTick(() => {
|
|
|
if (messagesContainer.value) {
|
|
|
@@ -269,10 +358,12 @@ onMounted(async () => {
|
|
|
await loadMessages();
|
|
|
scrollToBottom();
|
|
|
connectWebSocket();
|
|
|
+ window.addEventListener('keydown', handleKeydown);
|
|
|
});
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
disconnectWebSocket();
|
|
|
+ window.removeEventListener('keydown', handleKeydown);
|
|
|
});
|
|
|
|
|
|
watch(() => props.orderId, async () => {
|