feat(communication): add chat reactions emojis and images
This commit is contained in:
65
frontend/components/ChatComposer.vue
Normal file
65
frontend/components/ChatComposer.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script setup>
|
||||
import { prepareChatImage } from '~/utils/chat-image'
|
||||
|
||||
const props = defineProps({ modelValue: { type: String, default: '' }, image: { type: Object, default: null }, busy: Boolean, disabled: Boolean,
|
||||
placeholder: { type: String, default: 'Nachricht schreiben …' } })
|
||||
const emit = defineEmits(['update:modelValue', 'update:image', 'send'])
|
||||
const input = ref(null), textarea = ref(null), preparing = ref(false), error = ref(''), emojisOpen = ref(false)
|
||||
const emojis = ['😀', '😊', '😂', '🥰', '😎', '🤔', '😮', '😢', '😅', '🙈', '👍', '👎', '👋', '🙏', '💪', '👏', '❤️', '🎉', '🔥', '✅', '❌', '🚀', '💡', '📅', '📷', '☕', '🎂', '🎁', '🛠️', '🏗️', '🤝', '👌']
|
||||
let generation = 0
|
||||
const locked = computed(() => props.disabled || props.busy || preparing.value)
|
||||
function send(event) {
|
||||
if (event?.isComposing || locked.value || (!props.modelValue.trim() && !props.image)) return
|
||||
emojisOpen.value = false; emit('send')
|
||||
}
|
||||
async function insertEmoji(emoji) {
|
||||
if (locked.value) return
|
||||
const start = textarea.value?.selectionStart ?? props.modelValue.length, end = textarea.value?.selectionEnd ?? start
|
||||
const value = props.modelValue.slice(0, start) + emoji + props.modelValue.slice(end)
|
||||
if (value.length > 10000) return
|
||||
emit('update:modelValue', value); emojisOpen.value = false
|
||||
await nextTick(); textarea.value?.focus(); textarea.value?.setSelectionRange(start + emoji.length, start + emoji.length)
|
||||
}
|
||||
async function attach(file) {
|
||||
if (!file || locked.value) return
|
||||
const version = ++generation
|
||||
preparing.value = true; error.value = ''
|
||||
try {
|
||||
const image = await prepareChatImage(file)
|
||||
if (version === generation && !props.disabled) emit('update:image', image)
|
||||
} catch (e) { if (version === generation) error.value = e.message || 'Das Bild konnte nicht gelesen werden.' }
|
||||
finally { if (version === generation) preparing.value = false }
|
||||
}
|
||||
function paste(event) {
|
||||
const image = [...(event.clipboardData?.files || [])].find(file => file.type.startsWith('image/'))
|
||||
if (image) { event.preventDefault(); void attach(image) }
|
||||
}
|
||||
function drop(event) {
|
||||
if (event.dataTransfer?.files?.length) { event.preventDefault(); void attach(event.dataTransfer.files[0]) }
|
||||
}
|
||||
watch(() => props.disabled, disabled => { if (disabled) { ++generation; preparing.value = false; emojisOpen.value = false } })
|
||||
onBeforeUnmount(() => { ++generation })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="shrink-0 space-y-2 border-t border-gray-200 bg-white p-3 text-gray-900 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 sm:p-4" @submit.prevent="send" @dragover.prevent @drop="drop">
|
||||
<p v-if="error" role="alert" class="text-sm text-red-700">{{ error }}</p>
|
||||
<div v-if="image" class="flex items-center gap-3 rounded-lg bg-gray-50 p-2 dark:bg-gray-800">
|
||||
<img :src="image.dataUrl" :alt="image.name" class="size-16 rounded object-contain" />
|
||||
<span class="min-w-0 flex-1 truncate text-xs">{{ image.name }}</span>
|
||||
<UButton icon="i-heroicons-x-mark" aria-label="Bild entfernen" color="neutral" variant="ghost" :disabled="locked" @click="emit('update:image', null)" />
|
||||
</div>
|
||||
<div v-if="emojisOpen" class="flex max-h-32 flex-wrap gap-1 overflow-y-auto rounded-lg border border-gray-200 p-2" role="group" aria-label="Emoji auswählen">
|
||||
<button v-for="emoji in emojis" :key="emoji" type="button" :aria-label="`${emoji} einfügen`" class="rounded p-2 text-xl hover:bg-gray-100 focus-visible:ring-2 focus-visible:ring-green-600" @click="insertEmoji(emoji)">{{ emoji }}</button>
|
||||
</div>
|
||||
<label class="block"><span class="sr-only">Nachricht</span><textarea ref="textarea" :value="modelValue" rows="2" maxlength="10000" :disabled="locked" :placeholder="placeholder"
|
||||
class="max-h-36 w-full resize-y rounded-lg border border-gray-300 bg-transparent p-2 text-sm dark:border-gray-600" @input="emit('update:modelValue', $event.target.value)" @keydown.enter.exact.prevent="send" @paste="paste" /></label>
|
||||
<div class="flex items-center gap-1">
|
||||
<UButton icon="i-heroicons-face-smile" aria-label="Emojis auswählen" title="Emojis" color="neutral" variant="ghost" :aria-expanded="emojisOpen" :disabled="locked" @click="emojisOpen = !emojisOpen" />
|
||||
<UButton icon="i-heroicons-photo" aria-label="Bild anhängen" title="Bild anhängen (JPEG, PNG, WebP, bis 10 MB)" color="neutral" variant="ghost" :loading="preparing" :disabled="locked" @click="input?.click()" />
|
||||
<input ref="input" type="file" accept="image/jpeg,image/png,image/webp" class="hidden" aria-label="Bilddatei" :disabled="locked" @change="attach($event.target.files?.[0]); $event.target.value = ''" />
|
||||
<span class="hidden min-w-0 flex-1 text-xs text-gray-400 sm:block">Enter sendet · Umschalt+Enter für neue Zeile</span>
|
||||
<UButton type="submit" class="ml-auto" icon="i-heroicons-paper-airplane" :loading="busy" :disabled="locked || (!modelValue.trim() && !image)">Senden</UButton>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
Reference in New Issue
Block a user