|
|
@@ -25,6 +25,17 @@
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
+ <!-- Context Menu -->
|
|
|
+ <div v-if="contextMenu.visible"
|
|
|
+ class="fixed z-[200] bg-card/90 backdrop-blur-md border border-border/50 shadow-xl rounded-xl py-1 min-w-[120px] animate-in fade-in zoom-in-95 duration-100"
|
|
|
+ :style="{ top: `${contextMenu.y}px`, left: `${contextMenu.x}px` }"
|
|
|
+ @click.stop>
|
|
|
+ <button @click="deleteMessage(contextMenu.msgId)"
|
|
|
+ class="w-full text-left px-4 py-2 text-sm text-rose-500 hover:bg-rose-500/10 font-bold transition-colors flex items-center gap-2">
|
|
|
+ <Trash2 class="w-4 h-4" /> {{ t("admin.actions.delete") }}
|
|
|
+ </button>
|
|
|
+ </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">
|
|
|
@@ -44,10 +55,11 @@
|
|
|
:class="msg.is_from_admin ? 'justify-start' : 'justify-end'"
|
|
|
>
|
|
|
<div
|
|
|
- class="max-w-[80%] px-3.5 py-2 rounded-2xl text-sm leading-relaxed shadow-sm"
|
|
|
+ class="max-w-[80%] px-3.5 py-2 rounded-2xl text-sm leading-relaxed shadow-sm transition-opacity hover:opacity-95"
|
|
|
:class="msg.is_from_admin
|
|
|
? 'bg-secondary/80 text-foreground rounded-bl-md'
|
|
|
: 'bg-primary text-primary-foreground rounded-br-md'"
|
|
|
+ @contextmenu.prevent="openContextMenu($event, msg)"
|
|
|
>
|
|
|
<div v-if="msg.is_from_admin" class="flex items-center gap-1.5 mb-1">
|
|
|
<ShieldCheck class="w-3 h-3 opacity-70" />
|
|
|
@@ -100,8 +112,8 @@
|
|
|
<script setup lang="ts">
|
|
|
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, RESOURCES_BASE_URL } from "@/lib/api";
|
|
|
+import { MessageCircle, Send, Loader2, ShieldCheck, X, Eye, Trash2 } from "lucide-vue-next";
|
|
|
+import { getOrderMessages, sendOrderMessage, deleteOrderMessage, RESOURCES_BASE_URL } from "@/lib/api";
|
|
|
import { useAuthStore } from "@/stores/auth";
|
|
|
import { toast } from "vue-sonner";
|
|
|
|
|
|
@@ -127,6 +139,38 @@ const textareaRef = ref<HTMLTextAreaElement | null>(null);
|
|
|
const otherPartyOnline = ref(false);
|
|
|
const selectedImage = ref<string | null>(null);
|
|
|
let cooldownInterval: ReturnType<typeof setInterval> | null = null;
|
|
|
+const contextMenu = ref({ visible: false, x: 0, y: 0, msgId: null as number | null });
|
|
|
+
|
|
|
+const openContextMenu = (e: MouseEvent, msg: any) => {
|
|
|
+ const myRole = authStore.user?.role;
|
|
|
+ const isMine = (myRole === 'admin' && msg.is_from_admin) || (myRole !== 'admin' && !msg.is_from_admin);
|
|
|
+ if (myRole !== 'admin' && !isMine) return; // Users can only delete their own messages, Admin can delete any
|
|
|
+
|
|
|
+ // Keep menu on screen
|
|
|
+ let x = e.clientX;
|
|
|
+ let y = e.clientY;
|
|
|
+ if (window.innerWidth - x < 150) x -= 120;
|
|
|
+ if (window.innerHeight - y < 100) y -= 50;
|
|
|
+
|
|
|
+ contextMenu.value = { visible: true, x, y, msgId: msg.id };
|
|
|
+};
|
|
|
+
|
|
|
+const closeContextMenu = () => {
|
|
|
+ contextMenu.value.visible = false;
|
|
|
+};
|
|
|
+
|
|
|
+async function deleteMessage(msgId: number | null) {
|
|
|
+ if (!msgId) return;
|
|
|
+ try {
|
|
|
+ await deleteOrderMessage(props.orderId, msgId, locale.value);
|
|
|
+ messages.value = messages.value.filter(m => m.id !== msgId);
|
|
|
+ toast.success("Message deleted");
|
|
|
+ } catch (err: any) {
|
|
|
+ toast.error(err.message);
|
|
|
+ } finally {
|
|
|
+ closeContextMenu();
|
|
|
+ }
|
|
|
+}
|
|
|
|
|
|
const parsedMessages = computed(() => {
|
|
|
return messages.value.map(msg => {
|
|
|
@@ -185,6 +229,11 @@ function connectWebSocket() {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
+ if (msg.type === "message_deleted") {
|
|
|
+ messages.value = messages.value.filter(m => m.id != msg.id);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
// If it's a message (type 'message' or has essential fields)
|
|
|
if (msg.type === "message" || (msg.message && msg.id)) {
|
|
|
// Use lenient comparison (==) for ID to avoid string/int mismatches
|
|
|
@@ -366,11 +415,15 @@ onMounted(async () => {
|
|
|
scrollToBottom();
|
|
|
connectWebSocket();
|
|
|
window.addEventListener('keydown', handleKeydown);
|
|
|
+ document.addEventListener('click', closeContextMenu);
|
|
|
+ document.addEventListener('scroll', closeContextMenu, true);
|
|
|
});
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
disconnectWebSocket();
|
|
|
window.removeEventListener('keydown', handleKeydown);
|
|
|
+ document.removeEventListener('click', closeContextMenu);
|
|
|
+ document.removeEventListener('scroll', closeContextMenu, true);
|
|
|
});
|
|
|
|
|
|
watch(() => props.orderId, async () => {
|