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

409 lines
25 KiB
Vue

<script setup>
const toast = useToast()
const { $api } = useNuxtApp()
const auth = useAuthStore()
const route = useRoute()
const router = useRouter()
const baseRooms = ref([]), projectRooms = ref([]), directRooms = ref([]), collaborationRooms = ref([])
const invitations = ref([]), invitationBusy = ref(''), collaborationError = ref(''), listError = ref('')
const unreadRooms = ref({}), messages = ref([]), members = ref([]), draft = ref(''), draftImage = ref(null)
const requestedRoom = () => typeof route.query.room === 'string' ? route.query.room : 'allgemein'
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)
let pollTimer, listTimer, pollBusy = false, mounted = false, disposed = false
let scopeVersion = 0, roomVersion = 0, collaborationRequest = 0, nativeListRequest = 0, syncAfterId = 0
const currentScope = version => !disposed && version === scopeVersion
const selection = () => ({ scope: scopeVersion, version: roomVersion, key: activeRoomKey.value })
const currentSelection = request => currentScope(request.scope) && request.version === roomVersion && request.key === activeRoomKey.value
const errorMessage = error => error?.data?.message || error?.data?.error || 'Die Anfrage konnte nicht ausgeführt werden.'
// Colons cannot occur in normalized internal room keys, so both room types stay unambiguous.
const collaborationKey = id => `collaboration:${id}`
const nativeEndpoint = (key, suffix = '') => `/api/communication/chat/rooms/${encodeURIComponent(key)}${suffix}`
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, source: 'internal', unread: unreadRooms.value[room.key]?.count || 0 })),
...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: visibleRooms.value.filter(room => room.group === label) }))
.filter(group => group.rooms.length))
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')
function clearMessages() { messages.value = []; members.value = []; draft.value = ''; draftImage.value = null; syncAfterId = 0; roomLoading.value = false; sending.value = false }
function 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 = [...byId.values()].sort((a, b) => Number(a.id) - Number(b.id))
syncAfterId = Number(messages.value.at(-1)?.id || 0)
}
const scrollToBottom = async request => {
await nextTick()
if (currentSelection(request) && messagesViewport.value) messagesViewport.value.scrollTop = messagesViewport.value.scrollHeight
}
async function loadUnread(scope = scopeVersion) {
if (!hasSession.value || !currentScope(scope)) return
try {
const result = await $api('/api/communication/chat/unread')
if (currentScope(scope)) unreadRooms.value = result.rooms || {}
} catch { /* A count failure must not interrupt an open conversation. */ }
}
async function loadRoomLists(scope = scopeVersion) {
if (!hasSession.value || !currentScope(scope)) return
const request = ++nativeListRequest
try {
const [rooms, projects, directs] = await Promise.all([
$api('/api/communication/chat/rooms'), $api('/api/communication/chat/project-rooms'), $api('/api/communication/chat/direct-rooms')
])
if (!currentScope(scope) || request !== nativeListRequest) return
baseRooms.value = rooms.rooms || []; projectRooms.value = projects.rooms || []; directRooms.value = directs.rooms || []
listError.value = ''
} catch (error) { if (currentScope(scope) && request === nativeListRequest) listError.value = errorMessage(error) }
}
async function loadCollaboration(scope = scopeVersion) {
if (!hasSession.value || !currentScope(scope)) return
const request = ++collaborationRequest
try {
const result = await $api('/api/collaboration')
if (!currentScope(scope) || request !== collaborationRequest) return
// Managed rooms alone never grant chat access. Only explicit memberships enter this list.
collaborationRooms.value = result.rooms || []; invitations.value = result.invitations || []
collaborationError.value = ''
} catch (error) {
if (!currentScope(scope) || request !== collaborationRequest) return
collaborationError.value = errorMessage(error)
if ([401, 403].includes(error?.statusCode || error?.status)) { collaborationRooms.value = []; invitations.value = [] }
}
}
async function refreshLists() {
const scope = scopeVersion
await Promise.all([loadRoomLists(scope), loadUnread(scope), loadCollaboration(scope)])
}
function refreshVisibleLists() {
if (!disposed && !document.hidden && !loading.value && hasSession.value) void refreshLists()
}
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)
try {
await $api(nativeEndpoint(request.key, '/read'), { method: 'POST', body: { messageId } })
if (currentSelection(request)) unreadRooms.value = { ...unreadRooms.value, [request.key]: { count: 0, mentions: 0 } }
} catch { /* Read receipts must not interrupt the conversation. */ }
}
async function loadActiveRoom(request = selection()) {
if (!currentSelection(request) || !isInternal.value || !activeRoom.value?.exists) return
roomLoading.value = true
try {
const [result, people] = await Promise.all([
$api(nativeEndpoint(request.key, '/messages')), $api(nativeEndpoint(request.key, '/members'))
])
if (!currentSelection(request)) return
messages.value = []; mergeMessages(result.messages); members.value = people.members || []
await markRead(request); await scrollToBottom(request)
} catch (error) {
if (currentSelection(request)) toast.add({ title: errorMessage(error), color: 'error' })
} 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 {
if (room.source !== 'collaboration' && !room.exists) {
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' })
if (!currentSelection(request)) return
await loadRoomLists(request.scope)
if (!currentSelection(request)) return
activeRoomKey.value = created.key; request = selection()
}
await router.replace({ query: { ...route.query, room: request.key } })
if (currentSelection(request)) await loadActiveRoom(request)
} catch (error) { if (currentSelection(request)) toast.add({ title: errorMessage(error), color: 'error' }) }
}
async function createRoom() {
if (!roomForm.name.trim() || creating.value) return
const scope = scopeVersion
creating.value = true
try {
const created = await $api('/api/communication/chat/rooms', {
method: 'POST', body: { name: roomForm.name, key: roomKeyPreview.value, topic: roomForm.topic }
})
if (!currentScope(scope)) return
Object.assign(roomForm, { name: '', key: '', topic: '' }); showRoomForm.value = false
await loadRoomLists(scope)
if (currentScope(scope)) await selectRoom(created)
} catch (error) { if (currentScope(scope)) toast.add({ title: errorMessage(error), color: 'error' }) }
finally { if (currentScope(scope)) creating.value = false }
}
async function respondToInvitation(id, accept) {
if (invitationBusy.value) return
const scope = scopeVersion
invitationBusy.value = id
try {
const result = await $api(`/api/collaboration/invitations/${encodeURIComponent(id)}/${accept ? 'accept' : 'decline'}`, { method: 'POST' })
if (!currentScope(scope)) return
await loadCollaboration(scope)
if (!currentScope(scope)) return
const room = decoratedRooms.value.find(room => room.key === collaborationKey(result.roomId))
if (accept && room) await selectRoom(room)
} catch (error) { if (currentScope(scope)) toast.add({ title: errorMessage(error), color: 'error' }) }
finally { if (currentScope(scope)) invitationBusy.value = '' }
}
async function sendMessage() {
const text = draft.value.trim(), image = draftImage.value, request = selection()
if ((!text && !image) || sending.value || !isInternal.value || !activeRoom.value?.exists) return
sending.value = true; draft.value = ''
try {
const message = await $api(nativeEndpoint(request.key, '/messages'), { method: 'POST', body: { text, ...(image ? { image } : {}) } })
if (!currentSelection(request)) return
draftImage.value = null; mergeMessages([message]); await markRead(request); await scrollToBottom(request)
} catch (error) {
if (currentSelection(request)) { draft.value = text; toast.add({ title: errorMessage(error), color: 'error' }) }
} finally { if (currentSelection(request)) sending.value = false }
}
async function pollMessages() {
if (pollBusy || loading.value || roomLoading.value || document.hidden || !isInternal.value || !activeRoom.value?.exists) return
const request = selection()
pollBusy = true
try {
const result = await $api(nativeEndpoint(request.key, `/sync?afterId=${syncAfterId}`))
if (!currentSelection(request)) return
if (result.messages?.length) { mergeMessages(result.messages); await markRead(request); await scrollToBottom(request) }
await loadUnread(request.scope)
} catch (error) {
if (currentSelection(request) && [401, 403, 404, 410].includes(error?.statusCode || error?.status)) {
++roomVersion; clearMessages(); void loadRoomLists(request.scope)
}
} 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))
async function initialize() {
const scope = ++scopeVersion
++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 Promise.all([refreshLists(), loadRoomPreferences(scope)])
if (!currentScope(scope)) return
loading.value = false
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 = visibleRooms.value.find(room => room.key === requestedRoom())
if (room) void selectRoom(room)
else { ++roomVersion; activeRoomKey.value = requestedRoom(); clearMessages() }
})
onMounted(() => {
mounted = true; void initialize()
pollTimer = window.setInterval(pollMessages, 2500)
listTimer = window.setInterval(refreshVisibleLists, 15000)
window.addEventListener('focus', refreshVisibleLists)
document.addEventListener('visibilitychange', refreshVisibleLists)
})
onBeforeUnmount(() => {
disposed = true; ++scopeVersion; ++roomVersion; clearInterval(pollTimer); clearInterval(listTimer)
window.removeEventListener('focus', refreshVisibleLists)
document.removeEventListener('visibilitychange', refreshVisibleLists)
})
</script>
<template>
<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">
<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>
<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">
<p class="text-sm font-medium">{{ invitation.name }}</p>
<p class="text-xs text-gray-500">{{ invitation.ownerName }} · {{ invitation.role === 'reader' ? 'Nur lesen' : 'Lesen und schreiben' }}</p>
<p v-if="invitation.overviewAllowed" class="mt-1 text-xs text-gray-500">Projektübersicht freigegeben</p>
<div class="mt-2 flex gap-2">
<UButton size="xs" :disabled="!!invitationBusy" @click="respondToInvitation(invitation.id, true)">Annehmen</UButton>
<UButton size="xs" variant="ghost" color="neutral" :disabled="!!invitationBusy" @click="respondToInvitation(invitation.id, false)">Ablehnen</UButton>
</div>
</div>
</section>
<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>
<div
v-for="room in group.rooms"
:key="room.key"
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'"
>
<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="!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">
<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="!activeRoom" class="py-10 text-center text-sm text-gray-500">Dieser Chatraum ist nicht verfügbar. Wähle links einen anderen Raum.</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>
<ChatReactions v-else :key="`${session}:${activeRoomKey}`" v-slot="{ reactions, react, busy }" :api="$api" :endpoint="nativeEndpoint(activeRoomKey)" :messages="messages" @error="error => toast.add({ title: errorMessage(error), color: 'error' })">
<div 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>
<ChatImage v-if="message.image" :api="$api" :endpoint="nativeEndpoint(activeRoomKey)" :message="message" />
<p v-if="message.body" 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>
<ChatReactionBar :reactions="reactions[message.id] || []" :busy="busy" @react="react(message.id, $event)" />
</div>
</div>
</div>
</ChatReactions>
</div>
<ChatComposer :key="`${session}:${activeRoomKey}`" v-model="draft" v-model:image="draftImage" :busy="sending" :disabled="!activeRoom?.exists || roomLoading" @send="sendMessage" />
</main>
<aside v-if="!isCollaboration" 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>