feat(communication): add room visibility and project chat controls
All checks were successful
Build and Release FEDEO / verify (push) Successful in 57s
Build and Release FEDEO / build-and-release (push) Successful in 6m27s

This commit is contained in:
2026-10-02 18:27:02 +02:00
parent 0887863981
commit 7e6c2fbac5
14 changed files with 364 additions and 21 deletions

View File

@@ -12,6 +12,8 @@ const requestedRoom = () => typeof route.query.room === 'string' ? route.query.r
const activeRoomKey = ref(requestedRoom())
const loading = ref(true), roomLoading = ref(false), sending = ref(false), creating = ref(false)
const showRoomForm = ref(false), messagesViewport = ref(null)
const hiddenRoomKeys = ref([]), showHiddenRooms = ref(false), preferenceBusy = ref(''), preferenceError = ref('')
const preferencesReady = ref(false)
const roomForm = reactive({ name: '', key: '', topic: '' })
const session = computed(() => `${auth.user?.id || auth.user?.user_id || ''}:${auth.activeTenant || ''}`)
const hasSession = computed(() => !!auth.user && !!auth.activeTenant)
@@ -38,10 +40,12 @@ const decoratedRooms = computed(() => [
...collaborationRooms.value.map(room => ({ ...room, key: collaborationKey(room.id), source: 'collaboration', exists: true,
group: 'Zusammenarbeit', icon: 'i-heroicons-user-group', unread: room.unread || 0 }))
])
const visibleRooms = computed(() => decoratedRooms.value.filter(room => !hiddenRoomKeys.value.includes(room.key)))
const hiddenRooms = computed(() => decoratedRooms.value.filter(room => hiddenRoomKeys.value.includes(room.key)))
const groupedRooms = computed(() => ['Räume', 'Projekte', 'Direktnachrichten', 'Zusammenarbeit']
.map(label => ({ label, rooms: decoratedRooms.value.filter(room => room.group === label) }))
.map(label => ({ label, rooms: visibleRooms.value.filter(room => room.group === label) }))
.filter(group => group.rooms.length))
const activeRoom = computed(() => decoratedRooms.value.find(room => room.key === activeRoomKey.value) || null)
const activeRoom = computed(() => visibleRooms.value.find(room => room.key === activeRoomKey.value) || null)
const isCollaboration = computed(() => activeRoom.value?.source === 'collaboration')
const isInternal = computed(() => activeRoom.value?.source === 'internal')
@@ -96,6 +100,40 @@ async function refreshLists() {
await Promise.all([loadRoomLists(scope), loadUnread(scope), loadCollaboration(scope)])
}
async function loadRoomPreferences(scope = scopeVersion) {
if (!hasSession.value || !currentScope(scope)) return
try {
const result = await $api('/api/communication/chat/room-preferences')
if (!currentScope(scope)) return
hiddenRoomKeys.value = result.hiddenRoomKeys || []
preferencesReady.value = true; preferenceError.value = ''
} catch (error) {
if (currentScope(scope)) preferenceError.value = errorMessage(error)
}
}
async function setRoomHidden(room, hidden) {
if (preferenceBusy.value || !preferencesReady.value) return
const scope = scopeVersion
preferenceBusy.value = room.key
try {
await $api('/api/communication/chat/room-preferences', { method: 'PUT', body: { roomKey: room.key, hidden } })
if (!currentScope(scope)) return
hiddenRoomKeys.value = hidden
? [...new Set([...hiddenRoomKeys.value, room.key])]
: hiddenRoomKeys.value.filter(key => key !== room.key)
if (hidden && activeRoomKey.value === room.key) {
++roomVersion; activeRoomKey.value = ''; clearMessages()
const next = visibleRooms.value.find(candidate => candidate.exists)
if (next) await selectRoom(next)
else await router.replace({ query: { ...route.query, room: undefined } })
} else if (!hidden) {
const restored = visibleRooms.value.find(candidate => candidate.key === room.key)
if (restored) await selectRoom(restored)
}
} catch (error) { if (currentScope(scope)) toast.add({ title: errorMessage(error), color: 'error' }) }
finally { if (currentScope(scope)) preferenceBusy.value = '' }
}
async function markRead(request) {
if (!currentSelection(request) || !isInternal.value || !activeRoom.value?.exists) return
const messageId = Number(messages.value.at(-1)?.id || 0)
@@ -119,6 +157,7 @@ async function loadActiveRoom(request = selection()) {
} finally { if (currentSelection(request)) roomLoading.value = false }
}
async function selectRoom(room) {
if (hiddenRoomKeys.value.includes(room.key)) return
++roomVersion; activeRoomKey.value = room.key; clearMessages()
let request = selection()
try {
@@ -199,18 +238,22 @@ async function initialize() {
++roomVersion; clearMessages()
baseRooms.value = []; projectRooms.value = []; directRooms.value = []; collaborationRooms.value = []; invitations.value = []
unreadRooms.value = {}; invitationBusy.value = ''; collaborationError.value = ''; listError.value = ''; creating.value = false
hiddenRoomKeys.value = []; showHiddenRooms.value = false; preferenceBusy.value = ''; preferenceError.value = ''; preferencesReady.value = false
showRoomForm.value = false; Object.assign(roomForm, { name: '', key: '', topic: '' })
activeRoomKey.value = requestedRoom(); loading.value = true
await refreshLists()
await Promise.all([refreshLists(), loadRoomPreferences(scope)])
if (!currentScope(scope)) return
loading.value = false
const room = activeRoom.value || (activeRoomKey.value === 'allgemein' ? decoratedRooms.value[0] : null)
const room = activeRoom.value || (activeRoomKey.value === 'allgemein' ? visibleRooms.value.find(room => room.exists) : null)
if (room) await selectRoom(room)
}
watch(session, () => { if (mounted) void initialize() }, { flush: 'sync' })
watch(() => activeRoom.value?.key, (key, previous) => {
if (!key && previous && !loading.value) { ++roomVersion; clearMessages() }
})
watch(() => route.query.room, () => {
if (!mounted || loading.value || requestedRoom() === activeRoomKey.value) return
const room = decoratedRooms.value.find(room => room.key === requestedRoom())
const room = visibleRooms.value.find(room => room.key === requestedRoom())
if (room) void selectRoom(room)
else { ++roomVersion; activeRoomKey.value = requestedRoom(); clearMessages() }
})
@@ -223,8 +266,8 @@ onBeforeUnmount(() => { disposed = true; ++scopeVersion; ++roomVersion; clearInt
</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 grid-rows-[minmax(8rem,30%)_minmax(0,1fr)] overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm lg:grid-rows-1"
<div class="flex h-full min-h-0 min-w-0 w-full flex-1 overflow-hidden bg-white">
<div class="grid h-full min-h-0 w-full flex-1 grid-cols-1 grid-rows-[minmax(8rem,30%)_minmax(0,1fr)] overflow-hidden lg:grid-rows-1"
:class="isCollaboration ? 'lg:grid-cols-[19rem_minmax(0,1fr)]' : '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">
@@ -248,6 +291,7 @@ onBeforeUnmount(() => { disposed = true; ++scopeVersion; ++roomVersion; clearInt
<UButton to="/communication/collaboration" icon="i-heroicons-user-group" color="neutral" variant="outline" size="xs" class="mb-4 w-full justify-center">Unterräume und Freigaben</UButton>
<p v-if="listError" role="alert" class="mb-3 text-xs text-red-700">{{ listError }} <button class="underline" @click="loadRoomLists()">Erneut laden</button></p>
<p v-if="collaborationError" role="alert" class="mb-3 text-xs text-red-700">Zusammenarbeit: {{ collaborationError }} <button class="underline" @click="loadCollaboration()">Erneut laden</button></p>
<p v-if="preferenceError" role="alert" class="mb-3 text-xs text-red-700">Raumeinstellungen: {{ preferenceError }} <button class="underline" @click="loadRoomPreferences()">Erneut laden</button></p>
<section v-if="invitations.length" class="mb-5 space-y-2" aria-label="Einladungen">
<h2 class="px-2 text-xs font-semibold uppercase tracking-wide text-gray-400">Einladungen</h2>
<div v-for="invitation in invitations" :key="invitation.id" class="rounded-lg border border-primary-200 bg-primary-50 p-3">
@@ -265,26 +309,43 @@ onBeforeUnmount(() => { disposed = true; ++scopeVersion; ++roomVersion; clearInt
<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
<div
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="mb-1 flex w-full items-center rounded-lg 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.source === 'collaboration' ? room.ownerName || 'Projekt-Unterraum' : room.topic }}{{ room.remote ? ' · Extern' : '' }}{{ room.status === 'archived' ? ' · Archiviert' : '' }}</span>
</span>
<UBadge v-if="room.unread" color="primary" size="xs">{{ room.unread }}</UBadge>
<button
type="button"
class="flex min-w-0 flex-1 items-center gap-3 px-2 py-2 text-left"
@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.source === 'collaboration' ? room.ownerName || 'Projekt-Unterraum' : room.topic }}{{ room.remote ? ' · Extern' : '' }}{{ room.status === 'archived' ? ' · Archiviert' : '' }}</span>
</span>
<UBadge v-if="room.unread" color="primary" size="xs">{{ room.unread }}</UBadge>
</button>
<UButton icon="i-heroicons-eye-slash" color="neutral" variant="ghost" size="xs" class="mr-1 shrink-0" :aria-label="`${room.name} ausblenden`" title="Für mich ausblenden" :disabled="!preferencesReady || !!preferenceBusy" @click="setRoomHidden(room, true)" />
</div>
</section>
<p v-if="!visibleRooms.length" class="px-2 text-sm text-gray-500">Keine sichtbaren Räume. Du kannst ausgeblendete Räume unten wieder einblenden.</p>
<section v-if="hiddenRooms.length" aria-label="Ausgeblendete Räume">
<button type="button" class="flex w-full items-center justify-between px-2 py-2 text-left text-xs font-semibold text-gray-500" :aria-expanded="showHiddenRooms" @click="showHiddenRooms = !showHiddenRooms">
<span>Ausgeblendete Räume ({{ hiddenRooms.length }})</span><UIcon :name="showHiddenRooms ? 'i-heroicons-chevron-up' : 'i-heroicons-chevron-down'" />
</button>
<div v-if="showHiddenRooms" class="space-y-1">
<div v-for="room in hiddenRooms" :key="room.key" class="flex items-center gap-2 px-2 py-1">
<span class="min-w-0 flex-1 truncate text-sm text-gray-500">{{ room.name }}</span>
<UButton icon="i-heroicons-eye" variant="ghost" color="neutral" size="xs" :aria-label="`${room.name} einblenden`" :disabled="!!preferenceBusy" @click="setRoomHidden(room, false)">Einblenden</UButton>
</div>
</div>
</section>
</div>
</aside>
<CollaborationRoom v-if="isCollaboration" :key="`${session}:${activeRoom.id}`" :room-id="activeRoom.id" :api="$api" embedded @changed="loadCollaboration()" @unauthorized="loadCollaboration()">
<CollaborationRoom v-if="!loading && isCollaboration" :key="`${session}:${activeRoom.id}`" :room-id="activeRoom.id" :api="$api" embedded @changed="loadCollaboration()" @unauthorized="loadCollaboration()">
<template #actions><UButton :to="{ path: '/communication/collaboration', query: { room: activeRoom.id } }" color="neutral" variant="outline" size="xs">Details und Freigaben</UButton></template>
</CollaborationRoom>
<main v-else class="flex min-h-0 flex-col">