Files
FEDEO/frontend/pages/communication/chat.vue

306 lines
12 KiB
Vue

<script setup>
const toast = useToast()
const { $api } = useNuxtApp()
const route = useRoute()
const router = useRouter()
const baseRooms = ref([])
const projectRooms = ref([])
const directRooms = ref([])
const unreadRooms = ref({})
const messages = ref([])
const members = ref([])
const draft = ref("")
const activeRoomKey = ref(typeof route.query.room === "string" ? route.query.room : "allgemein")
const loading = ref(true)
const roomLoading = ref(false)
const sending = ref(false)
const creating = ref(false)
const showRoomForm = ref(false)
const messagesViewport = ref(null)
const roomForm = reactive({ name: "", key: "", topic: "" })
let pollTimer = null
let pollBusy = false
let syncAfterId = 0
const normalizeRoomKey = (value) => String(value || "")
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/ß/g, "ss")
.replace(/[^a-z0-9._=-]+/g, "_")
.replace(/_+/g, "_")
.replace(/^[._=-]+|[._=-]+$/g, "")
const roomKeyPreview = computed(() => normalizeRoomKey(roomForm.key || roomForm.name))
const decoratedRooms = computed(() => [
...baseRooms.value.map((room) => ({ ...room, group: "Räume", icon: "i-heroicons-chat-bubble-left-right" })),
...projectRooms.value.map((room) => ({ ...room, group: "Projekte", icon: "i-heroicons-briefcase" })),
...directRooms.value.map((room) => ({ ...room, group: "Direktnachrichten", icon: "i-heroicons-user-circle" }))
].map((room) => ({ ...room, unread: unreadRooms.value[room.key]?.count || 0 })))
const groupedRooms = computed(() => ["Räume", "Projekte", "Direktnachrichten"]
.map((label) => ({ label, rooms: decoratedRooms.value.filter((room) => room.group === label) }))
.filter((group) => group.rooms.length))
const activeRoom = computed(() => decoratedRooms.value.find((room) => room.key === activeRoomKey.value) || null)
const roomEndpoint = computed(() => `/api/communication/chat/rooms/${encodeURIComponent(activeRoomKey.value)}`)
const mergeMessages = (incoming) => {
const byId = new Map(messages.value.map((message) => [message.id, message]))
for (const message of incoming || []) byId.set(message.id, message)
messages.value = Array.from(byId.values()).sort((a, b) => Number(a.id) - Number(b.id))
syncAfterId = Number(messages.value.at(-1)?.id || syncAfterId)
}
const scrollToBottom = async () => {
await nextTick()
if (messagesViewport.value) messagesViewport.value.scrollTop = messagesViewport.value.scrollHeight
}
const loadUnread = async () => {
try {
unreadRooms.value = (await $api("/api/communication/chat/unread")).rooms || {}
} catch {
unreadRooms.value = {}
}
}
const loadRoomLists = async () => {
const [rooms, projects, directs] = await Promise.all([
$api("/api/communication/chat/rooms"),
$api("/api/communication/chat/project-rooms"),
$api("/api/communication/chat/direct-rooms")
])
baseRooms.value = rooms.rooms || []
projectRooms.value = projects.rooms || []
directRooms.value = directs.rooms || []
}
const markRead = async () => {
if (!activeRoom.value?.exists) return
try {
await $api(`${roomEndpoint.value}/read`, {
method: "POST",
body: { messageId: Number(messages.value.at(-1)?.id || 0) }
})
unreadRooms.value = { ...unreadRooms.value, [activeRoomKey.value]: { count: 0, mentions: 0 } }
} catch {
// Der Lesestatus darf die Unterhaltung nicht blockieren.
}
}
const loadActiveRoom = async () => {
if (!activeRoom.value?.exists) return
roomLoading.value = true
syncAfterId = 0
try {
const [messageResult, memberResult] = await Promise.all([
$api(`${roomEndpoint.value}/messages`),
$api(`${roomEndpoint.value}/members`)
])
messages.value = []
mergeMessages(messageResult.messages || [])
members.value = memberResult.members || []
await markRead()
await scrollToBottom()
} catch (error) {
toast.add({ title: error?.data?.error || "Chatraum konnte nicht geladen werden", color: "error" })
} finally {
roomLoading.value = false
}
}
const provisionRoom = async (room) => {
const endpoint = room.type === "project"
? `/api/communication/chat/project-rooms/${room.projectId}/provision`
: `/api/communication/chat/direct-rooms/${encodeURIComponent(room.userId)}/provision`
const created = await $api(endpoint, { method: "POST" })
await loadRoomLists()
activeRoomKey.value = created.key
await router.replace({ query: { ...route.query, room: created.key } })
}
const selectRoom = async (room) => {
try {
if (!room.exists) await provisionRoom(room)
else {
activeRoomKey.value = room.key
await router.replace({ query: { ...route.query, room: room.key } })
}
messages.value = []
members.value = []
await loadActiveRoom()
} catch (error) {
toast.add({ title: error?.data?.error || "Chatraum konnte nicht geöffnet werden", color: "error" })
}
}
const createRoom = async () => {
if (!roomForm.name.trim()) return
creating.value = true
try {
const created = await $api("/api/communication/chat/rooms", {
method: "POST",
body: { name: roomForm.name, key: roomKeyPreview.value, topic: roomForm.topic }
})
Object.assign(roomForm, { name: "", key: "", topic: "" })
showRoomForm.value = false
await loadRoomLists()
await selectRoom(created)
} catch (error) {
toast.add({ title: error?.data?.error || "Chatraum konnte nicht erstellt werden", color: "error" })
} finally {
creating.value = false
}
}
const sendMessage = async () => {
const text = draft.value.trim()
if (!text || sending.value || !activeRoom.value?.exists) return
sending.value = true
draft.value = ""
try {
const message = await $api(`${roomEndpoint.value}/messages`, { method: "POST", body: { text } })
mergeMessages([message])
await markRead()
await scrollToBottom()
} catch (error) {
draft.value = text
toast.add({ title: error?.data?.error || "Nachricht konnte nicht gesendet werden", color: "error" })
} finally {
sending.value = false
}
}
const pollMessages = async () => {
if (pollBusy || document.hidden || !activeRoom.value?.exists) return
pollBusy = true
try {
const result = await $api(`${roomEndpoint.value}/sync?afterId=${syncAfterId}`)
if (result.messages?.length) {
mergeMessages(result.messages)
await markRead()
await scrollToBottom()
}
await loadUnread()
} catch {
// Beim nächsten Intervall erneut versuchen.
} finally {
pollBusy = false
}
}
const formatMessageTime = (timestamp) => new Intl.DateTimeFormat("de-DE", {
day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit"
}).format(new Date(timestamp))
onMounted(async () => {
try {
await Promise.all([loadRoomLists(), loadUnread()])
if (!activeRoom.value) activeRoomKey.value = "allgemein"
await loadActiveRoom()
pollTimer = window.setInterval(pollMessages, 2500)
} catch (error) {
toast.add({ title: error?.data?.error || "Chat konnte nicht geladen werden", color: "error" })
} finally {
loading.value = false
}
})
onBeforeUnmount(() => {
if (pollTimer) window.clearInterval(pollTimer)
})
</script>
<template>
<div class="flex min-h-0 flex-1 bg-gray-50 p-4 sm:p-6">
<div class="mx-auto grid h-[calc(100vh-8rem)] w-full max-w-7xl grid-cols-1 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm lg:grid-cols-[19rem_minmax(0,1fr)_15rem]">
<aside class="min-h-0 overflow-y-auto border-r border-gray-200 p-4">
<div class="mb-4 flex items-center justify-between">
<div>
<h1 class="font-semibold text-gray-900">Chat</h1>
<p class="text-xs text-gray-500">FEDEO Kommunikation</p>
</div>
<UButton icon="i-heroicons-plus" color="neutral" variant="ghost" size="sm" @click="showRoomForm = !showRoomForm" />
</div>
<form v-if="showRoomForm" class="mb-4 space-y-2 rounded-lg bg-gray-50 p-3" @submit.prevent="createRoom">
<UInput v-model="roomForm.name" placeholder="Raumname" autofocus />
<UInput v-model="roomForm.key" :placeholder="roomKeyPreview || 'raumschluessel'" />
<UInput v-model="roomForm.topic" placeholder="Beschreibung (optional)" />
<div class="flex justify-end gap-2">
<UButton color="neutral" variant="ghost" size="xs" @click="showRoomForm = false">Abbrechen</UButton>
<UButton type="submit" size="xs" :loading="creating">Erstellen</UButton>
</div>
</form>
<div v-if="loading" class="py-8 text-center text-sm text-gray-500">Chat wird geladen </div>
<div v-else class="space-y-5">
<section v-for="group in groupedRooms" :key="group.label">
<h2 class="mb-1 px-2 text-xs font-semibold uppercase tracking-wide text-gray-400">{{ group.label }}</h2>
<button
v-for="room in group.rooms"
:key="room.key"
type="button"
class="mb-1 flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left transition hover:bg-gray-100"
:class="room.key === activeRoomKey ? 'bg-primary-50 text-primary-700' : 'text-gray-700'"
@click="selectRoom(room)"
>
<UIcon :name="room.icon" class="size-5 shrink-0" />
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-medium">{{ room.name }}</span>
<span class="block truncate text-xs text-gray-400">{{ room.topic }}</span>
</span>
<UBadge v-if="room.unread" color="primary" size="xs">{{ room.unread }}</UBadge>
</button>
</section>
</div>
</aside>
<main class="flex min-h-0 flex-col">
<header class="border-b border-gray-200 px-5 py-4">
<h2 class="font-semibold text-gray-900">{{ activeRoom?.name || "Chat" }}</h2>
<p class="truncate text-sm text-gray-500">{{ activeRoom?.topic || "Wähle links einen Chatraum aus." }}</p>
</header>
<div ref="messagesViewport" class="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-6">
<div v-if="roomLoading" class="py-10 text-center text-sm text-gray-500">Nachrichten werden geladen </div>
<div v-else-if="!messages.length" class="flex h-full flex-col items-center justify-center text-center text-gray-400">
<UIcon name="i-heroicons-chat-bubble-oval-left-ellipsis" class="mb-3 size-10" />
<p class="font-medium">Noch keine Nachrichten</p>
<p class="text-sm">Beginne die Unterhaltung.</p>
</div>
<div v-else class="space-y-4">
<div v-for="message in messages" :key="message.id" class="flex" :class="message.own ? 'justify-end' : 'justify-start'">
<div class="max-w-[80%] rounded-2xl px-4 py-2" :class="message.own ? 'bg-primary-600 text-white' : 'bg-gray-100 text-gray-900'">
<div v-if="!message.own" class="mb-1 text-xs font-semibold text-primary-600">{{ message.senderDisplayName }}</div>
<p class="whitespace-pre-wrap break-words text-sm">{{ message.body }}</p>
<div class="mt-1 text-right text-[11px]" :class="message.own ? 'text-primary-100' : 'text-gray-400'">{{ formatMessageTime(message.timestamp) }}</div>
</div>
</div>
</div>
</div>
<form class="flex items-end gap-3 border-t border-gray-200 p-4" @submit.prevent="sendMessage">
<UTextarea v-model="draft" class="flex-1" autoresize :rows="1" :maxrows="6" placeholder="Nachricht schreiben …" :disabled="!activeRoom?.exists" @keydown.enter.exact.prevent="sendMessage" />
<UButton type="submit" icon="i-heroicons-paper-airplane" :loading="sending" :disabled="!draft.trim() || !activeRoom?.exists">Senden</UButton>
</form>
</main>
<aside class="hidden min-h-0 overflow-y-auto border-l border-gray-200 p-4 lg:block">
<h2 class="mb-3 text-sm font-semibold text-gray-900">Teilnehmer</h2>
<div class="space-y-3">
<div v-for="member in members" :key="member.userId" class="flex items-center gap-3">
<div class="flex size-8 items-center justify-center rounded-full bg-primary-100 text-xs font-semibold text-primary-700">
{{ (member.displayName || "?").slice(0, 1).toUpperCase() }}
</div>
<div class="min-w-0">
<p class="truncate text-sm font-medium text-gray-800">{{ member.own ? "Du" : member.displayName }}</p>
<p class="truncate text-xs text-gray-400">{{ member.email }}</p>
</div>
</div>
</div>
</aside>
</div>
</div>
</template>