Compare commits
2 Commits
cf15a0f9c2
...
22b8392fd5
Author | SHA1 | Date | |
---|---|---|---|
22b8392fd5 | |||
9948d1c25b |
228
frontend-vue/-
Normal file
228
frontend-vue/-
Normal 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>
|
@@ -4,6 +4,7 @@
|
|||||||
'message',
|
'message',
|
||||||
{ 'message--unsent': isUnsent }
|
{ 'message--unsent': isUnsent }
|
||||||
]"
|
]"
|
||||||
|
ref="rootEl"
|
||||||
:data-message-id="message.id"
|
:data-message-id="message.id"
|
||||||
:tabindex="tabindex || -1"
|
:tabindex="tabindex || -1"
|
||||||
:aria-label="messageAriaLabel"
|
:aria-label="messageAriaLabel"
|
||||||
@@ -33,10 +34,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, ref, nextTick } from 'vue'
|
||||||
import { useAudio } from '@/composables/useAudio'
|
import { useAudio } from '@/composables/useAudio'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { apiService } from '@/services/api'
|
||||||
|
import { syncService } from '@/services/sync'
|
||||||
import { formatSmartTimestamp, formatTimestampForScreenReader } from '@/utils/time'
|
import { formatSmartTimestamp, formatTimestampForScreenReader } from '@/utils/time'
|
||||||
import FileAttachment from './FileAttachment.vue'
|
import FileAttachment from './FileAttachment.vue'
|
||||||
import type { ExtendedMessage, UnsentMessage, FileAttachment as FileAttachmentType } from '@/types'
|
import type { ExtendedMessage, UnsentMessage, FileAttachment as FileAttachmentType } from '@/types'
|
||||||
@@ -57,6 +60,17 @@ const { speak, playSound } = useAudio()
|
|||||||
const toastStore = useToastStore()
|
const toastStore = useToastStore()
|
||||||
const appStore = useAppStore()
|
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
|
// Check if message has a file attachment
|
||||||
const hasFileAttachment = computed(() => {
|
const hasFileAttachment = computed(() => {
|
||||||
return 'fileId' in props.message && !!props.message.fileId
|
return 'fileId' in props.message && !!props.message.fileId
|
||||||
@@ -160,6 +174,92 @@ const handleKeydown = (event: KeyboardEvent) => {
|
|||||||
} else {
|
} else {
|
||||||
toastStore.info('Text-to-speech is disabled')
|
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: optimistic removal, then server delete
|
||||||
|
const msg = props.message as ExtendedMessage
|
||||||
|
|
||||||
|
// Capture original position for potential rollback
|
||||||
|
const channelMessages = appStore.messages[msg.channel_id] || []
|
||||||
|
const originalIndex = channelMessages.findIndex(m => m.id === msg.id)
|
||||||
|
|
||||||
|
// Optimistically remove from local state for snappy UI
|
||||||
|
appStore.removeMessage(msg.id)
|
||||||
|
|
||||||
|
// Focus the closest message immediately after local removal
|
||||||
|
await nextTick()
|
||||||
|
if (targetToFocus && document.contains(targetToFocus)) {
|
||||||
|
if (!targetToFocus.hasAttribute('tabindex')) targetToFocus.setAttribute('tabindex', '-1')
|
||||||
|
targetToFocus.focus()
|
||||||
|
} else {
|
||||||
|
focusFallbackToInput()
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiService.deleteMessage(msg.channel_id, msg.id)
|
||||||
|
// Attempt to sync the channel to reconcile with server state
|
||||||
|
try {
|
||||||
|
await syncService.syncChannelMessages(msg.channel_id)
|
||||||
|
} catch (syncError) {
|
||||||
|
console.warn('Post-delete sync failed; continuing with local state.', syncError)
|
||||||
|
}
|
||||||
|
toastStore.success('Message deleted')
|
||||||
|
} catch (error) {
|
||||||
|
// Rollback local removal on failure
|
||||||
|
if (originalIndex !== -1) {
|
||||||
|
const list = appStore.messages[msg.channel_id] || []
|
||||||
|
list.splice(Math.min(originalIndex, list.length), 0, msg)
|
||||||
|
}
|
||||||
|
await nextTick()
|
||||||
|
const restoredEl = document.querySelector(`[data-message-id="${msg.id}"]`) as HTMLElement | null
|
||||||
|
if (restoredEl) {
|
||||||
|
if (!restoredEl.hasAttribute('tabindex')) restoredEl.setAttribute('tabindex', '-1')
|
||||||
|
restoredEl.focus()
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
// 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>
|
</script>
|
||||||
@@ -239,4 +339,4 @@ const handleKeydown = (event: KeyboardEvent) => {
|
|||||||
color: #a0aec0;
|
color: #a0aec0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@@ -8,7 +8,11 @@ export class SyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sync messages for a channel: merge server data with local data
|
* Sync messages for a channel: replace local data with server data
|
||||||
|
*
|
||||||
|
* Prunes any local messages that are no longer present on the server
|
||||||
|
* instead of keeping them around. We still keep unsent messages in the
|
||||||
|
* separate unsent queue handled elsewhere.
|
||||||
*/
|
*/
|
||||||
async syncChannelMessages(channelId: number): Promise<void> {
|
async syncChannelMessages(channelId: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -19,55 +23,35 @@ export class SyncService {
|
|||||||
// Get server messages
|
// Get server messages
|
||||||
const serverResponse = await apiService.getMessages(channelId)
|
const serverResponse = await apiService.getMessages(channelId)
|
||||||
const serverMessages = serverResponse.messages
|
const serverMessages = serverResponse.messages
|
||||||
|
|
||||||
// Get local messages
|
console.log(`Server has ${serverMessages.length} messages, replacing local set for channel ${channelId}`)
|
||||||
const localMessages = appStore.messages[channelId] || []
|
|
||||||
|
// Transform and sort server messages only (pruning locals not on server)
|
||||||
console.log(`Server has ${serverMessages.length} messages, local has ${localMessages.length} messages`)
|
const normalizedServerMessages: ExtendedMessage[] = serverMessages
|
||||||
|
.map((msg: any) => {
|
||||||
// Merge messages using a simple strategy:
|
const transformedMsg: ExtendedMessage = {
|
||||||
// 1. Create a map of all messages by ID
|
id: msg.id,
|
||||||
// 2. Server messages take precedence (they may have been updated)
|
channel_id: msg.channelId || msg.channel_id,
|
||||||
// 3. Keep local messages that don't exist on server (may be unsent)
|
content: msg.content,
|
||||||
|
created_at: msg.createdAt || msg.created_at,
|
||||||
const messageMap = new Map<number, ExtendedMessage>()
|
file_id: msg.fileId || msg.file_id,
|
||||||
|
// Map the flattened file fields from backend
|
||||||
// Add local messages first
|
fileId: msg.fileId,
|
||||||
localMessages.forEach(msg => {
|
filePath: msg.filePath,
|
||||||
if (typeof msg.id === 'number') {
|
fileType: msg.fileType,
|
||||||
messageMap.set(msg.id, msg)
|
fileSize: msg.fileSize,
|
||||||
}
|
originalName: msg.originalName,
|
||||||
})
|
fileCreatedAt: msg.fileCreatedAt
|
||||||
|
}
|
||||||
// Add/update with server messages (server wins for conflicts)
|
console.log(`Sync: Processing message ${msg.id}, has file:`, !!msg.fileId, `(${msg.originalName})`)
|
||||||
serverMessages.forEach((msg: any) => {
|
return transformedMsg
|
||||||
// Transform server message format to match our types
|
})
|
||||||
const transformedMsg: ExtendedMessage = {
|
.sort((a: ExtendedMessage, b: ExtendedMessage) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
|
||||||
id: msg.id,
|
|
||||||
channel_id: msg.channelId || msg.channel_id,
|
console.log(`Pruned + normalized result: ${normalizedServerMessages.length} messages`)
|
||||||
content: msg.content,
|
|
||||||
created_at: msg.createdAt || msg.created_at,
|
// Update local storage with server truth
|
||||||
file_id: msg.fileId || msg.file_id,
|
appStore.setMessages(channelId, normalizedServerMessages)
|
||||||
// Map the flattened file fields from backend
|
|
||||||
fileId: msg.fileId,
|
|
||||||
filePath: msg.filePath,
|
|
||||||
fileType: msg.fileType,
|
|
||||||
fileSize: msg.fileSize,
|
|
||||||
originalName: msg.originalName,
|
|
||||||
fileCreatedAt: msg.fileCreatedAt
|
|
||||||
}
|
|
||||||
console.log(`Sync: Processing message ${msg.id}, has file:`, !!msg.fileId, `(${msg.originalName})`)
|
|
||||||
messageMap.set(msg.id, transformedMsg)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Convert back to array, sorted by creation time
|
|
||||||
const mergedMessages = Array.from(messageMap.values())
|
|
||||||
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
|
|
||||||
|
|
||||||
console.log(`Merged result: ${mergedMessages.length} messages`)
|
|
||||||
|
|
||||||
// Update local storage
|
|
||||||
appStore.setMessages(channelId, mergedMessages)
|
|
||||||
await appStore.saveState()
|
await appStore.saveState()
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -302,4 +286,4 @@ export class SyncService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const syncService = new SyncService()
|
export const syncService = new SyncService()
|
||||||
|
@@ -71,9 +71,22 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
if (!messages.value[message.channel_id]) {
|
if (!messages.value[message.channel_id]) {
|
||||||
messages.value[message.channel_id] = []
|
messages.value[message.channel_id] = []
|
||||||
}
|
}
|
||||||
messages.value[message.channel_id].push(message)
|
|
||||||
console.log('Store: Messages for channel', message.channel_id, 'now has', messages.value[message.channel_id].length, 'messages')
|
const channelMessages = messages.value[message.channel_id]
|
||||||
|
const existingIndex = channelMessages.findIndex(m => m.id === message.id)
|
||||||
|
|
||||||
|
if (existingIndex !== -1) {
|
||||||
|
// Upsert: update existing to avoid duplicates from WebSocket vs sync
|
||||||
|
channelMessages[existingIndex] = { ...channelMessages[existingIndex], ...message }
|
||||||
|
} else {
|
||||||
|
channelMessages.push(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep chronological order by created_at
|
||||||
|
channelMessages.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
|
||||||
|
|
||||||
|
console.log('Store: Messages for channel', message.channel_id, 'now has', channelMessages.length, 'messages')
|
||||||
|
|
||||||
// Note: Auto-save is now handled by the sync service to avoid excessive I/O
|
// Note: Auto-save is now handled by the sync service to avoid excessive I/O
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,4 +188,4 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
loadState,
|
loadState,
|
||||||
saveState
|
saveState
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
Reference in New Issue
Block a user