feat: implement delete

This commit is contained in:
2025-08-24 07:13:17 +02:00
parent cf15a0f9c2
commit 9948d1c25b
2 changed files with 292 additions and 2 deletions

228
frontend-vue/- Normal file
View File

@@ -0,0 +1,228 @@
<template>
<div class="base-textarea">
<label v-if="label" :for="textareaId" class="base-textarea__label">
{{ label }}
<span v-if="required" class="base-textarea__required">*</span>
</label>
<div class="base-textarea__wrapper">
<textarea
:id="textareaId"
ref="textareaRef"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:required="required"
:rows="rows"
:maxlength="maxlength"
:aria-invalid="error ? 'true' : 'false'"
:aria-describedby="error ? `${textareaId}-error` : undefined"
:class="[
'base-textarea__field',
{ 'base-textarea__field--error': error }
]"
@input="handleInput"
@blur="$emit('blur', $event)"
@focus="$emit('focus', $event)"
@keydown="handleKeydown"
@keyup="$emit('keyup', $event)"
/>
</div>
<div v-if="showCharCount && maxlength" class="base-textarea__char-count">
{{ modelValue.length }}/{{ maxlength }}
</div>
<div v-if="error" :id="`${textareaId}-error`" class="base-textarea__error">
{{ error }}
</div>
<div v-else-if="helpText" class="base-textarea__help">
{{ helpText }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
interface Props {
modelValue: string
label?: string
placeholder?: string
disabled?: boolean
readonly?: boolean
required?: boolean
rows?: number
maxlength?: number
showCharCount?: boolean
error?: string
helpText?: string
id?: string
autoResize?: boolean
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
readonly: false,
required: false,
rows: 3,
showCharCount: false,
autoResize: false
})
const emit = defineEmits<{
'update:modelValue': [value: string]
blur: [event: FocusEvent]
focus: [event: FocusEvent]
keydown: [event: KeyboardEvent]
keyup: [event: KeyboardEvent]
submit: []
}>()
const textareaRef = ref<HTMLTextAreaElement>()
const textareaId = computed(() => props.id || `textarea-${Math.random().toString(36).substr(2, 9)}`)
const handleInput = (event: Event) => {
const target = event.target as HTMLTextAreaElement
emit('update:modelValue', target.value)
if (props.autoResize) {
autoResize(target)
}
}
const handleKeydown = (event: KeyboardEvent) => {
emit('keydown', event)
// Submit on Ctrl+Enter or Cmd+Enter
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
event.preventDefault()
emit('submit')
}
}
const autoResize = (textarea: HTMLTextAreaElement) => {
textarea.style.height = 'auto'
textarea.style.height = textarea.scrollHeight + 'px'
}
const focus = () => {
textareaRef.value?.focus()
}
const selectAll = () => {
textareaRef.value?.select()
}
defineExpose({
focus,
selectAll,
textareaRef
})
</script>
<style scoped>
.base-textarea {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.base-textarea__label {
font-size: 0.875rem;
font-weight: 500;
color: #374151;
}
.base-textarea__required {
color: #ef4444;
}
.base-textarea__wrapper {
position: relative;
}
.base-textarea__field {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 1rem;
font-family: inherit;
background-color: #ffffff;
color: #111827;
transition: all 0.2s ease;
outline: none;
resize: vertical;
min-height: 3rem;
}
.base-textarea__field:focus {
border-color: #646cff;
box-shadow: 0 0 0 3px rgba(100, 108, 255, 0.1);
}
.base-textarea__field:disabled {
background-color: #f9fafb;
color: #9ca3af;
cursor: not-allowed;
resize: none;
}
.base-textarea__field:readonly {
background-color: #f9fafb;
cursor: default;
resize: none;
}
.base-textarea__field--error {
border-color: #ef4444;
}
.base-textarea__field--error:focus {
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
.base-textarea__char-count {
font-size: 0.75rem;
color: #6b7280;
text-align: right;
}
.base-textarea__error {
font-size: 0.875rem;
color: #ef4444;
}
.base-textarea__help {
font-size: 0.875rem;
color: #6b7280;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.base-textarea__label {
color: rgba(255, 255, 255, 0.87);
}
.base-textarea__field {
background-color: #374151;
color: rgba(255, 255, 255, 0.87);
border-color: #4b5563;
}
.base-textarea__field:disabled,
.base-textarea__field:readonly {
background-color: #1f2937;
color: #9ca3af;
}
.base-textarea__help,
.base-textarea__char-count {
color: #9ca3af;
}
}
</style>

View File

@@ -4,6 +4,7 @@
'message',
{ 'message--unsent': isUnsent }
]"
ref="rootEl"
:data-message-id="message.id"
:tabindex="tabindex || -1"
:aria-label="messageAriaLabel"
@@ -33,10 +34,11 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref, nextTick } from 'vue'
import { useAudio } from '@/composables/useAudio'
import { useToastStore } from '@/stores/toast'
import { useAppStore } from '@/stores/app'
import { apiService } from '@/services/api'
import { formatSmartTimestamp, formatTimestampForScreenReader } from '@/utils/time'
import FileAttachment from './FileAttachment.vue'
import type { ExtendedMessage, UnsentMessage, FileAttachment as FileAttachmentType } from '@/types'
@@ -57,6 +59,17 @@ const { speak, playSound } = useAudio()
const toastStore = useToastStore()
const appStore = useAppStore()
// Root element ref for DOM-based focus management
const rootEl = ref<HTMLElement | null>(null)
// Fallback: focus the chat input textarea
const focusFallbackToInput = () => {
const inputEl = document.querySelector('.message-input .base-textarea__field') as HTMLElement | null
if (inputEl) {
inputEl.focus()
}
}
// Check if message has a file attachment
const hasFileAttachment = computed(() => {
return 'fileId' in props.message && !!props.message.fileId
@@ -160,6 +173,55 @@ const handleKeydown = (event: KeyboardEvent) => {
} else {
toastStore.info('Text-to-speech is disabled')
}
} else if (event.key === 'Delete') {
event.preventDefault()
handleDelete()
}
}
// Delete current message (supports sent and unsent)
const handleDelete = async () => {
try {
// Capture neighboring elements before removal
const current = rootEl.value
const prevEl = (current?.previousElementSibling as HTMLElement | null) || null
const nextEl = (current?.nextElementSibling as HTMLElement | null) || null
const isFirst = !prevEl
const targetToFocus = isFirst ? nextEl : prevEl
if (props.isUnsent) {
// Unsent local message
const unsent = props.message as UnsentMessage
appStore.removeUnsentMessage(unsent.id)
toastStore.success('Unsent message removed')
// focus the closest message
await nextTick()
if (targetToFocus && document.contains(targetToFocus)) {
if (!targetToFocus.hasAttribute('tabindex')) targetToFocus.setAttribute('tabindex', '-1')
targetToFocus.focus()
} else {
focusFallbackToInput()
}
return
}
// Sent message: call API then update store
const msg = props.message as ExtendedMessage
await apiService.deleteMessage(msg.channel_id, msg.id)
appStore.removeMessage(msg.id)
toastStore.success('Message deleted')
// focus the closest message
await nextTick()
if (targetToFocus && document.contains(targetToFocus)) {
if (!targetToFocus.hasAttribute('tabindex')) targetToFocus.setAttribute('tabindex', '-1')
targetToFocus.focus()
} else {
focusFallbackToInput()
}
} catch (error) {
console.error('Failed to delete message:', error)
toastStore.error('Failed to delete message')
}
}
</script>
@@ -239,4 +301,4 @@ const handleKeydown = (event: KeyboardEvent) => {
color: #a0aec0;
}
}
</style>
</style>