Files
FEDEO/frontend/components/CollaborationRoom.vue

122 lines
7.5 KiB
Vue

<script setup>
const props = defineProps({ roomId: { type: String, required: true }, api: { type: Function, required: true }, base: { type: String, default: '/api/collaboration' } })
const emit = defineEmits(['changed', 'unauthorized'])
const room = ref(null), messages = ref([]), members = ref([]), draft = ref(''), error = ref('')
const loading = ref(false), sending = ref(false), olderLoading = ref(false), hasMore = ref(false), viewport = ref(null)
let generation = 0, timer, polling = false, pending = null
const endpoint = (id, suffix = '') => `${props.base}/rooms/${encodeURIComponent(id)}${suffix}`
const merge = (incoming) => {
const byId = new Map(messages.value.map(m => [m.id, m]))
incoming.forEach(m => byId.set(m.id, m))
messages.value = [...byId.values()].sort((a, b) => a.seq - b.seq)
}
const messageError = e => e?.data?.message || e?.data?.error || 'Unterraum konnte nicht geladen werden.'
const scroll = async () => { await nextTick(); if (viewport.value) viewport.value.scrollTop = viewport.value.scrollHeight }
function failed(e) {
error.value = messageError(e)
if ([401, 403, 404, 410].includes(e?.statusCode || e?.status)) {
room.value = null; messages.value = []; members.value = []
if ((e?.statusCode || e?.status) === 401) emit('unauthorized')
}
}
async function read(id, version) {
if (version !== generation || document.hidden || !messages.value.length) return
await props.api(endpoint(id, '/read'), { method: 'POST', body: { seq: messages.value.at(-1).seq } })
if (version === generation) emit('changed')
}
async function load() {
const version = ++generation, id = props.roomId
room.value = null; messages.value = []; members.value = []; draft.value = ''; error.value = ''; pending = null
loading.value = true
try {
const [detail, result, people] = await Promise.all([props.api(endpoint(id)), props.api(endpoint(id, '/messages')), props.api(endpoint(id, '/members'))])
if (version !== generation) return
room.value = detail; members.value = people.members; merge(result.messages); hasMore.value = result.hasMore
await scroll(); await read(id, version)
} catch (e) { if (version === generation) failed(e) }
finally { if (version === generation) loading.value = false }
}
async function poll() {
if (polling || loading.value || !room.value || document.hidden) return
const version = generation, id = props.roomId
polling = true
try {
const after = messages.value.at(-1)?.seq || 0
const [detail, result] = await Promise.all([props.api(endpoint(id)), props.api(endpoint(id, `/messages?after=${after}`))])
if (version !== generation) return
room.value = detail
const nearBottom = !viewport.value || viewport.value.scrollHeight - viewport.value.scrollTop - viewport.value.clientHeight < 90
if (result.messages.length) {
merge(result.messages)
if (nearBottom) { await scroll(); await read(id, version) }
}
error.value = ''
} catch (e) { if (version === generation) failed(e) }
finally { polling = false }
}
async function older() {
if (!messages.value.length || olderLoading.value) return
const version = generation, id = props.roomId, height = viewport.value?.scrollHeight || 0
olderLoading.value = true
try {
const result = await props.api(endpoint(id, `/messages?before=${messages.value[0].seq}`))
if (version !== generation) return
merge(result.messages); hasMore.value = result.hasMore
await nextTick(); if (viewport.value) viewport.value.scrollTop += viewport.value.scrollHeight - height
} catch (e) { if (version === generation) failed(e) }
finally { olderLoading.value = false }
}
async function send() {
const text = draft.value.trim()
if (!text || sending.value || !room.value?.canSend) return
const version = generation, id = props.roomId
if (!pending || pending.text !== text) pending = { text, clientId: crypto.randomUUID() }
sending.value = true; error.value = ''
try {
const message = await props.api(endpoint(id, '/messages'), { method: 'POST', body: pending })
if (version !== generation) return
merge([message]); draft.value = ''; pending = null
await scroll(); await read(id, version)
} catch (e) { if (version === generation) failed(e) }
finally { sending.value = false }
}
const time = value => new Date(value).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })
watch(() => props.roomId, load, { immediate: true })
onMounted(() => { timer = setInterval(poll, 3000) })
onBeforeUnmount(() => { ++generation; clearInterval(timer) })
</script>
<template>
<section class="flex min-h-[32rem] min-w-0 flex-col rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-900">
<header class="border-b border-gray-200 p-4 dark:border-gray-700">
<h2 class="text-lg font-semibold">{{ room?.name || 'Unterraum' }}</h2>
<p v-if="room?.topic" class="mt-1 text-sm text-gray-500">{{ room.topic }}</p>
<p v-if="members.length" class="mt-2 text-xs text-gray-500">{{ members.map(m => `${m.name}${m.kind === 'guest' ? ' (Gast)' : ''}`).join(' · ') }}</p>
</header>
<p v-if="error" role="alert" class="m-4 rounded-lg bg-red-50 p-3 text-sm text-red-700">{{ error }} <button class="ml-2 underline" @click="load">Erneut laden</button></p>
<div v-if="room?.overview && Object.keys(room.overview).length" class="m-4 rounded-lg bg-green-50 p-3 text-sm text-gray-800">
<h3 class="mb-1 font-semibold">Freigegebene Projektinformationen</h3>
<p v-if="room.overview.title">{{ room.overview.title }}</p>
<p v-if="room.overview.status">Status: {{ room.overview.status }}</p>
<p v-if="room.overview.date">Termin: {{ room.overview.date }}</p>
<p v-if="room.overview.description" class="mt-1 whitespace-pre-wrap">{{ room.overview.description }}</p>
</div>
<div ref="viewport" class="max-h-[60vh] min-h-[18rem] flex-1 space-y-3 overflow-y-auto p-4" aria-live="polite">
<p v-if="loading" class="text-sm text-gray-500">Nachrichten werden geladen …</p>
<button v-if="hasMore" class="block w-full text-center text-sm text-green-700 underline" :disabled="olderLoading" @click="older">{{ olderLoading ? 'Wird geladen …' : 'Ältere Nachrichten laden' }}</button>
<p v-if="room && !loading && !messages.length" class="py-12 text-center text-sm text-gray-500">Hier beginnt eure Unterhaltung. Du siehst Nachrichten ab deinem Beitritt.</p>
<div v-for="message in messages" :key="message.id" class="flex" :class="message.own ? 'justify-end' : 'justify-start'">
<div class="max-w-[85%] rounded-xl px-4 py-2" :class="message.own ? 'bg-green-700 text-white' : 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-100'">
<p class="text-xs font-semibold">{{ message.own ? 'Du' : message.senderName }}</p>
<p class="whitespace-pre-wrap break-words text-sm">{{ message.body }}</p>
<p class="mt-1 text-right text-[10px] opacity-70">{{ time(message.createdAt) }}</p>
</div>
</div>
</div>
<form v-if="room" class="flex items-end gap-3 border-t border-gray-200 p-4 dark:border-gray-700" @submit.prevent="send">
<label class="min-w-0 flex-1"><span class="sr-only">Nachricht</span><textarea v-model="draft" rows="2" maxlength="10000" :disabled="!room.canSend" :placeholder="room.canSend ? 'Nachricht schreiben …' : 'Dieser Unterraum ist nur lesbar'" class="w-full resize-none rounded-lg border border-gray-300 p-2 text-sm dark:border-gray-600" @keydown.enter.exact.prevent="send" /></label>
<UButton type="submit" :loading="sending" :disabled="!room.canSend || !draft.trim()">Senden</UButton>
</form>
</section>
</template>