Initial vue frontend
This commit is contained in:
70
frontend-vue/src/components/chat/ChatHeader.vue
Normal file
70
frontend-vue/src/components/chat/ChatHeader.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<header class="chat-header">
|
||||
<h2 class="chat-title">{{ channelName }}</h2>
|
||||
<div class="chat-actions">
|
||||
<BaseButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="$emit('search')"
|
||||
aria-label="Search messages"
|
||||
>
|
||||
🔍
|
||||
</BaseButton>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
interface Props {
|
||||
channelName: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
defineEmits<{
|
||||
search: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
background: white;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
min-height: 64px;
|
||||
}
|
||||
|
||||
.chat-title {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.chat-header {
|
||||
background: #1f2937;
|
||||
border-bottom-color: #374151;
|
||||
}
|
||||
|
||||
.chat-title {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
}
|
||||
</style>
|
106
frontend-vue/src/components/chat/FileAttachment.vue
Normal file
106
frontend-vue/src/components/chat/FileAttachment.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div class="file-attachment">
|
||||
<!-- Image files -->
|
||||
<ImageMessage
|
||||
v-if="isImageFile"
|
||||
:file="file"
|
||||
/>
|
||||
|
||||
<!-- Audio/voice files -->
|
||||
<VoiceMessage
|
||||
v-else-if="isAudioFile"
|
||||
:file="file"
|
||||
/>
|
||||
|
||||
<!-- Other files -->
|
||||
<FileMessage
|
||||
v-else
|
||||
:file="file"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import ImageMessage from './ImageMessage.vue'
|
||||
import VoiceMessage from './VoiceMessage.vue'
|
||||
import FileMessage from './FileMessage.vue'
|
||||
import type { FileAttachment } from '@/types'
|
||||
|
||||
interface Props {
|
||||
file: FileAttachment
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const fileExtension = computed(() => {
|
||||
return props.file.original_name.split('.').pop()?.toLowerCase() || ''
|
||||
})
|
||||
|
||||
const isImageFile = computed(() => {
|
||||
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp']
|
||||
return imageExtensions.includes(fileExtension.value)
|
||||
})
|
||||
|
||||
const isAudioFile = computed(() => {
|
||||
const audioExtensions = ['mp3', 'wav', 'webm', 'ogg', 'aac', 'm4a']
|
||||
return audioExtensions.includes(fileExtension.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-attachment {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.file-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #f3f4f6;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
color: #374151;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.file-link:hover {
|
||||
background: #e5e7eb;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.file-link {
|
||||
background: #374151;
|
||||
border-color: #4b5563;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
|
||||
.file-link:hover {
|
||||
background: #4b5563;
|
||||
border-color: #6b7280;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
}
|
||||
</style>
|
458
frontend-vue/src/components/chat/FileMessage.vue
Normal file
458
frontend-vue/src/components/chat/FileMessage.vue
Normal file
@@ -0,0 +1,458 @@
|
||||
<template>
|
||||
<div class="file-message">
|
||||
<div
|
||||
class="file-container"
|
||||
@click="handleFileClick"
|
||||
:class="{ 'clickable': isPreviewable }"
|
||||
>
|
||||
<div class="file-icon">
|
||||
<Icon :name="fileIcon" size="md" />
|
||||
</div>
|
||||
|
||||
<div class="file-info">
|
||||
<div class="file-name">{{ file.original_name }}</div>
|
||||
<div class="file-meta">
|
||||
<span class="file-size">{{ formatFileSize(file.file_size) }}</span>
|
||||
<span class="file-type">{{ file.file_type }}</span>
|
||||
</div>
|
||||
<div v-if="isPreviewable" class="preview-hint">
|
||||
Click to preview
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click.stop="downloadFile"
|
||||
class="download-button"
|
||||
title="Download"
|
||||
>
|
||||
<Icon name="download" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- File preview modal -->
|
||||
<teleport to="body">
|
||||
<div
|
||||
v-if="showPreview"
|
||||
class="file-modal"
|
||||
@click="showPreview = false"
|
||||
@keydown.escape="showPreview = false"
|
||||
>
|
||||
<div class="modal-content" @click.stop>
|
||||
<div class="modal-header">
|
||||
<h3>{{ file.original_name }}</h3>
|
||||
<button @click="showPreview = false" class="close-button">
|
||||
<Icon name="x" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="preview-container">
|
||||
<!-- Text preview -->
|
||||
<div v-if="isTextFile && previewContent" class="text-preview">
|
||||
<pre>{{ previewContent }}</pre>
|
||||
</div>
|
||||
|
||||
<!-- PDF preview -->
|
||||
<iframe
|
||||
v-else-if="isPdfFile"
|
||||
:src="fileUrl"
|
||||
class="pdf-preview"
|
||||
></iframe>
|
||||
|
||||
<!-- Generic file info -->
|
||||
<div v-else class="file-details">
|
||||
<Icon :name="fileIcon" size="xl" />
|
||||
<p>Cannot preview this file type</p>
|
||||
<button @click="downloadFile" class="download-file-button">
|
||||
<Icon name="download" size="sm" />
|
||||
Download File
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { apiService } from '@/services/api'
|
||||
import Icon from '@/components/base/Icon.vue'
|
||||
import type { FileAttachment } from '@/types'
|
||||
|
||||
interface Props {
|
||||
file: FileAttachment
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const showPreview = ref(false)
|
||||
const previewContent = ref<string>('')
|
||||
const loading = ref(false)
|
||||
|
||||
const fileUrl = computed(() => apiService.getFileUrl(props.file.file_path))
|
||||
|
||||
const fileExtension = computed(() => {
|
||||
return props.file.original_name.split('.').pop()?.toLowerCase() || ''
|
||||
})
|
||||
|
||||
const fileIcon = computed(() => {
|
||||
const ext = fileExtension.value
|
||||
|
||||
if (['pdf'].includes(ext)) {
|
||||
return 'file-text'
|
||||
} else if (['doc', 'docx'].includes(ext)) {
|
||||
return 'file-text'
|
||||
} else if (['xls', 'xlsx'].includes(ext)) {
|
||||
return 'table'
|
||||
} else if (['zip', 'rar', '7z'].includes(ext)) {
|
||||
return 'archive'
|
||||
} else if (['txt', 'md', 'json', 'xml', 'csv'].includes(ext)) {
|
||||
return 'file-text'
|
||||
} else {
|
||||
return 'file'
|
||||
}
|
||||
})
|
||||
|
||||
const isTextFile = computed(() => {
|
||||
const textExtensions = ['txt', 'md', 'json', 'xml', 'csv', 'log', 'js', 'ts', 'css', 'html']
|
||||
return textExtensions.includes(fileExtension.value)
|
||||
})
|
||||
|
||||
const isPdfFile = computed(() => {
|
||||
return fileExtension.value === 'pdf'
|
||||
})
|
||||
|
||||
const isPreviewable = computed(() => {
|
||||
return isTextFile.value || isPdfFile.value
|
||||
})
|
||||
|
||||
const handleFileClick = async () => {
|
||||
if (!isPreviewable.value) {
|
||||
downloadFile()
|
||||
return
|
||||
}
|
||||
|
||||
if (isTextFile.value && !previewContent.value) {
|
||||
await loadTextPreview()
|
||||
}
|
||||
|
||||
showPreview.value = true
|
||||
}
|
||||
|
||||
const loadTextPreview = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await fetch(fileUrl.value)
|
||||
const text = await response.text()
|
||||
|
||||
// Limit preview size to prevent UI issues
|
||||
if (text.length > 50000) {
|
||||
previewContent.value = text.slice(0, 50000) + '\n\n... (file truncated for preview)'
|
||||
} else {
|
||||
previewContent.value = text
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load file preview:', error)
|
||||
previewContent.value = 'Error loading file preview'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const downloadFile = async () => {
|
||||
try {
|
||||
const response = await fetch(fileUrl.value)
|
||||
const blob = await response.blob()
|
||||
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = blobUrl
|
||||
link.download = props.file.original_name
|
||||
link.click()
|
||||
|
||||
// Clean up the blob URL after download
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 100)
|
||||
} catch (error) {
|
||||
console.error('Failed to download file:', error)
|
||||
// Fallback to direct link
|
||||
const link = document.createElement('a')
|
||||
link.href = fileUrl.value
|
||||
link.download = props.file.original_name
|
||||
link.target = '_blank'
|
||||
link.click()
|
||||
}
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-message {
|
||||
margin: 0.5rem 0;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.file-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
background: #f3f4f6;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.file-container.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-container.clickable:hover {
|
||||
background: #e5e7eb;
|
||||
border-color: #3b82f6;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.file-meta {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.preview-hint {
|
||||
font-size: 0.75rem;
|
||||
color: #3b82f6;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.download-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.download-button:hover {
|
||||
background: #f9fafb;
|
||||
color: #374151;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Modal styles */
|
||||
.file-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.text-preview {
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.text-preview pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
color: #374151;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pdf-preview {
|
||||
width: 100%;
|
||||
height: 70vh;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.file-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.download-file-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem 1.5rem;
|
||||
cursor: pointer;
|
||||
margin-top: 1rem;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.download-file-button:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.file-container {
|
||||
background: #374151;
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
.file-container.clickable:hover {
|
||||
background: #4b5563;
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
|
||||
.file-meta {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.preview-hint {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.download-button {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
.download-button:hover {
|
||||
background: #4b5563;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
border-color: #6b7280;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #1f2937;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
border-bottom-color: #374151;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
|
||||
.close-button {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
|
||||
.text-preview pre {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
|
||||
.file-details {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
}
|
||||
</style>
|
256
frontend-vue/src/components/chat/ImageMessage.vue
Normal file
256
frontend-vue/src/components/chat/ImageMessage.vue
Normal file
@@ -0,0 +1,256 @@
|
||||
<template>
|
||||
<div class="image-message">
|
||||
<div
|
||||
class="image-thumbnail"
|
||||
@click="showFullSize = true"
|
||||
:style="{ cursor: 'pointer' }"
|
||||
>
|
||||
<img
|
||||
:src="imageUrl"
|
||||
:alt="file.original_name"
|
||||
class="thumbnail"
|
||||
@error="imageError = true"
|
||||
/>
|
||||
<div class="image-overlay">
|
||||
<Icon name="search" size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="image-info">
|
||||
<span class="image-name">{{ file.original_name }}</span>
|
||||
<span class="image-size">{{ formatFileSize(file.file_size) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Full-size image modal -->
|
||||
<teleport to="body">
|
||||
<div
|
||||
v-if="showFullSize"
|
||||
class="image-modal"
|
||||
@click="showFullSize = false"
|
||||
@keydown.escape="showFullSize = false"
|
||||
>
|
||||
<div class="modal-content" @click.stop>
|
||||
<img
|
||||
:src="imageUrl"
|
||||
:alt="file.original_name"
|
||||
class="full-image"
|
||||
/>
|
||||
<div class="modal-actions">
|
||||
<button @click="downloadImage" class="action-button">
|
||||
<Icon name="download" size="sm" />
|
||||
Download
|
||||
</button>
|
||||
<button @click="showFullSize = false" class="action-button">
|
||||
<Icon name="x" size="sm" />
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { apiService } from '@/services/api'
|
||||
import Icon from '@/components/base/Icon.vue'
|
||||
import type { FileAttachment } from '@/types'
|
||||
|
||||
interface Props {
|
||||
file: FileAttachment
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const showFullSize = ref(false)
|
||||
const imageError = ref(false)
|
||||
|
||||
const imageUrl = computed(() => apiService.getFileUrl(props.file.file_path))
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const downloadImage = async () => {
|
||||
try {
|
||||
const response = await fetch(imageUrl.value)
|
||||
const blob = await response.blob()
|
||||
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = blobUrl
|
||||
link.download = props.file.original_name
|
||||
link.click()
|
||||
|
||||
// Clean up the blob URL after download
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 100)
|
||||
} catch (error) {
|
||||
console.error('Failed to download image:', error)
|
||||
// Fallback to direct link
|
||||
const link = document.createElement('a')
|
||||
link.href = imageUrl.value
|
||||
link.download = props.file.original_name
|
||||
link.target = '_blank'
|
||||
link.click()
|
||||
}
|
||||
}
|
||||
|
||||
// Close modal on escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && showFullSize.value) {
|
||||
showFullSize.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-message {
|
||||
margin: 0.5rem 0;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.image-thumbnail {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e5e7eb;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.image-thumbnail:hover {
|
||||
border-color: #3b82f6;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.image-thumbnail:hover .image-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 200px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.image-overlay {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 0.5rem;
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.image-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
background: #f9fafb;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.image-name {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.image-size {
|
||||
color: #6b7280;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* Modal styles */
|
||||
.image-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: relative;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.full-image {
|
||||
max-width: 100%;
|
||||
max-height: 80vh;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-button:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.image-thumbnail {
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
.image-thumbnail:hover {
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
|
||||
.image-info {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
.image-name {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
|
||||
.image-size {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
}
|
||||
</style>
|
73
frontend-vue/src/components/chat/InputActions.vue
Normal file
73
frontend-vue/src/components/chat/InputActions.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div class="input-actions">
|
||||
<BaseButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="$emit('file-upload')"
|
||||
aria-label="Upload file"
|
||||
:disabled="disabled"
|
||||
>
|
||||
📎
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="$emit('camera')"
|
||||
aria-label="Take photo"
|
||||
:disabled="disabled"
|
||||
>
|
||||
📷
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="$emit('voice')"
|
||||
aria-label="Record voice message"
|
||||
:disabled="disabled"
|
||||
>
|
||||
🎤
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
@click="$emit('send')"
|
||||
:disabled="!canSend || disabled"
|
||||
aria-label="Send message"
|
||||
>
|
||||
Send
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean
|
||||
canSend?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
disabled: false,
|
||||
canSend: false
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
'file-upload': []
|
||||
'camera': []
|
||||
'voice': []
|
||||
'send': []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.input-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
97
frontend-vue/src/components/chat/MessageInput.vue
Normal file
97
frontend-vue/src/components/chat/MessageInput.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div class="message-input-container">
|
||||
<div class="message-input">
|
||||
<BaseTextarea
|
||||
v-model="messageText"
|
||||
placeholder="Type a message..."
|
||||
:rows="1"
|
||||
auto-resize
|
||||
@keydown="handleInputKeydown"
|
||||
@submit="handleSubmit"
|
||||
ref="textareaRef"
|
||||
/>
|
||||
|
||||
<InputActions
|
||||
:disabled="isDisabled"
|
||||
:can-send="canSend"
|
||||
@file-upload="$emit('file-upload')"
|
||||
@camera="$emit('camera')"
|
||||
@voice="$emit('voice')"
|
||||
@send="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useAudio } from '@/composables/useAudio'
|
||||
import BaseTextarea from '@/components/base/BaseTextarea.vue'
|
||||
import InputActions from './InputActions.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
'send-message': [content: string]
|
||||
'file-upload': []
|
||||
'camera': []
|
||||
'voice': []
|
||||
}>()
|
||||
|
||||
const appStore = useAppStore()
|
||||
const { playWater, playSent } = useAudio()
|
||||
|
||||
const messageText = ref('')
|
||||
const textareaRef = ref()
|
||||
|
||||
const currentChannelId = computed(() => appStore.currentChannelId)
|
||||
const isDisabled = computed(() => !currentChannelId.value)
|
||||
const canSend = computed(() => messageText.value.trim().length > 0 && !!currentChannelId.value)
|
||||
|
||||
const handleInputKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!canSend.value) return
|
||||
|
||||
const content = messageText.value.trim()
|
||||
messageText.value = ''
|
||||
|
||||
playWater()
|
||||
emit('send-message', content)
|
||||
}
|
||||
|
||||
const focus = () => {
|
||||
textareaRef.value?.focus()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focus
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-input-container {
|
||||
padding: 1rem;
|
||||
background: white;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.message-input {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.75rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.message-input-container {
|
||||
background: #1f2937;
|
||||
border-top-color: #374151;
|
||||
}
|
||||
}
|
||||
</style>
|
277
frontend-vue/src/components/chat/MessageItem.vue
Normal file
277
frontend-vue/src/components/chat/MessageItem.vue
Normal file
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'message',
|
||||
{ 'message--unsent': isUnsent }
|
||||
]"
|
||||
:data-message-id="message.id"
|
||||
:tabindex="tabindex || 0"
|
||||
:aria-label="messageAriaLabel"
|
||||
role="listitem"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<div class="message__content">
|
||||
{{ message.content }}
|
||||
</div>
|
||||
|
||||
<!-- File Attachment -->
|
||||
<div v-if="hasFileAttachment && fileAttachment" class="message__files">
|
||||
<FileAttachment :file="fileAttachment" />
|
||||
</div>
|
||||
|
||||
<div class="message__meta">
|
||||
<time v-if="!isUnsent && 'created_at' in message" class="message__time">
|
||||
{{ formatTime(message.created_at) }}
|
||||
</time>
|
||||
<span v-else class="message__status">Sending...</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useAudio } from '@/composables/useAudio'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import FileAttachment from './FileAttachment.vue'
|
||||
import type { ExtendedMessage, UnsentMessage, FileAttachment as FileAttachmentType } from '@/types'
|
||||
|
||||
interface Props {
|
||||
message: ExtendedMessage | UnsentMessage
|
||||
isUnsent?: boolean
|
||||
tabindex?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isUnsent: false
|
||||
})
|
||||
|
||||
// Debug message structure (removed for production)
|
||||
|
||||
const { speak, playSound } = useAudio()
|
||||
const toastStore = useToastStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
// Check if message has a file attachment
|
||||
const hasFileAttachment = computed(() => {
|
||||
return 'fileId' in props.message && !!props.message.fileId
|
||||
})
|
||||
|
||||
// Create FileAttachment object from flattened message data
|
||||
const fileAttachment = computed((): FileAttachmentType | null => {
|
||||
if (!hasFileAttachment.value || !('fileId' in props.message)) return null
|
||||
|
||||
return {
|
||||
id: props.message.fileId!,
|
||||
channel_id: props.message.channel_id,
|
||||
message_id: props.message.id,
|
||||
file_path: props.message.filePath!,
|
||||
file_type: props.message.fileType!,
|
||||
file_size: props.message.fileSize!,
|
||||
original_name: props.message.originalName!,
|
||||
created_at: props.message.fileCreatedAt || props.message.created_at
|
||||
}
|
||||
})
|
||||
|
||||
const formatTime = (timestamp: string): string => {
|
||||
return new Date(timestamp).toLocaleTimeString()
|
||||
}
|
||||
|
||||
// Create comprehensive aria-label for screen readers
|
||||
const messageAriaLabel = computed(() => {
|
||||
let label = ''
|
||||
|
||||
// Add message content
|
||||
if (props.message.content) {
|
||||
label += props.message.content
|
||||
}
|
||||
|
||||
// Add file attachment info if present
|
||||
if (hasFileAttachment.value && fileAttachment.value) {
|
||||
const file = fileAttachment.value
|
||||
const fileType = getFileType(file.original_name)
|
||||
label += `. Has ${fileType} attachment: ${file.original_name}`
|
||||
}
|
||||
|
||||
// Add timestamp
|
||||
if ('created_at' in props.message && props.message.created_at) {
|
||||
const time = formatTime(props.message.created_at)
|
||||
label += `. Sent at ${time}`
|
||||
}
|
||||
|
||||
// Add status for unsent messages
|
||||
if (props.isUnsent) {
|
||||
label += '. Message is sending'
|
||||
}
|
||||
|
||||
return label
|
||||
})
|
||||
|
||||
// Helper to determine file type for better description
|
||||
const getFileType = (filename: string): string => {
|
||||
const ext = filename.split('.').pop()?.toLowerCase()
|
||||
if (!ext) return 'file'
|
||||
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(ext)) {
|
||||
return 'image'
|
||||
} else if (['mp3', 'wav', 'webm', 'ogg', 'aac', 'm4a'].includes(ext)) {
|
||||
return 'voice'
|
||||
} else if (['pdf'].includes(ext)) {
|
||||
return 'PDF document'
|
||||
} else if (['doc', 'docx'].includes(ext)) {
|
||||
return 'Word document'
|
||||
} else if (['txt', 'md'].includes(ext)) {
|
||||
return 'text document'
|
||||
} else {
|
||||
return 'file'
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
// Don't interfere with normal keyboard shortcuts (Ctrl+C, Ctrl+V, etc.)
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'c') {
|
||||
// Copy message content (only when no modifiers are pressed)
|
||||
navigator.clipboard.writeText(props.message.content)
|
||||
playSound('copy')
|
||||
toastStore.success('Message copied to clipboard')
|
||||
} else if (event.key === 'r') {
|
||||
// Read message aloud (only when no modifiers are pressed)
|
||||
if (appStore.settings.ttsEnabled) {
|
||||
speak(props.message.content)
|
||||
toastStore.info('Reading message')
|
||||
} else {
|
||||
toastStore.info('Text-to-speech is disabled')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message {
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 0.5rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.message:hover,
|
||||
.message:focus {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
border-color: #e5e7eb;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.message:focus {
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.message--unsent {
|
||||
opacity: 0.7;
|
||||
background: #fef3c7;
|
||||
border-color: #fbbf24;
|
||||
}
|
||||
|
||||
.message--highlighted {
|
||||
background: #dbeafe;
|
||||
border-color: #3b82f6;
|
||||
animation: highlight-fade 2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes highlight-fade {
|
||||
0% {
|
||||
background: #bfdbfe;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
100% {
|
||||
background: #dbeafe;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
.message__content {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
color: #111827;
|
||||
margin-bottom: 0.5rem;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.message__files {
|
||||
margin: 0.5rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.message__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.message__time {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.message__status {
|
||||
font-size: 0.75rem;
|
||||
color: #f59e0b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.message:hover,
|
||||
.message:focus {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
.message:focus {
|
||||
border-color: #60a5fa;
|
||||
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.1);
|
||||
}
|
||||
|
||||
.message--unsent {
|
||||
background: #451a03;
|
||||
border-color: #92400e;
|
||||
}
|
||||
|
||||
.message--highlighted {
|
||||
background: #1e3a8a;
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
|
||||
@keyframes highlight-fade {
|
||||
0% {
|
||||
background: #1e40af;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
100% {
|
||||
background: #1e3a8a;
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
}
|
||||
|
||||
.message__content {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
|
||||
.message__time {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.message__status {
|
||||
color: #fbbf24;
|
||||
}
|
||||
}
|
||||
</style>
|
250
frontend-vue/src/components/chat/MessagesContainer.vue
Normal file
250
frontend-vue/src/components/chat/MessagesContainer.vue
Normal file
@@ -0,0 +1,250 @@
|
||||
<template>
|
||||
<div
|
||||
class="messages-container"
|
||||
ref="containerRef"
|
||||
@keydown="handleKeydown"
|
||||
tabindex="0"
|
||||
role="list"
|
||||
:aria-label="messagesAriaLabel"
|
||||
:aria-description="navigationHint"
|
||||
>
|
||||
<div class="messages" role="presentation">
|
||||
<!-- Regular Messages -->
|
||||
<MessageItem
|
||||
v-for="(message, index) in messages"
|
||||
:key="message.id"
|
||||
:message="message"
|
||||
:tabindex="index === focusedMessageIndex ? 0 : -1"
|
||||
:data-message-index="index"
|
||||
@focus="focusedMessageIndex = index"
|
||||
/>
|
||||
|
||||
<!-- Unsent Messages -->
|
||||
<MessageItem
|
||||
v-for="(unsentMsg, index) in unsentMessages"
|
||||
:key="unsentMsg.id"
|
||||
:message="unsentMsg"
|
||||
:is-unsent="true"
|
||||
:tabindex="(messages.length + index) === focusedMessageIndex ? 0 : -1"
|
||||
:data-message-index="messages.length + index"
|
||||
@focus="focusedMessageIndex = messages.length + index"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick, watch, computed } from 'vue'
|
||||
import MessageItem from './MessageItem.vue'
|
||||
import type { ExtendedMessage, UnsentMessage } from '@/types'
|
||||
|
||||
interface Props {
|
||||
messages: ExtendedMessage[]
|
||||
unsentMessages: UnsentMessage[]
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
'message-selected': [message: ExtendedMessage | UnsentMessage, index: number]
|
||||
}>()
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const containerRef = ref<HTMLElement>()
|
||||
const focusedMessageIndex = ref(0)
|
||||
|
||||
// Combined messages array for easier navigation
|
||||
const allMessages = computed(() => [...props.messages, ...props.unsentMessages])
|
||||
const totalMessages = computed(() => allMessages.value.length)
|
||||
|
||||
// ARIA labels for screen readers
|
||||
const messagesAriaLabel = computed(() => {
|
||||
const total = totalMessages.value
|
||||
const current = focusedMessageIndex.value + 1
|
||||
|
||||
if (total === 0) {
|
||||
return 'Messages list, no messages'
|
||||
} else if (total === 1) {
|
||||
return 'Messages list, 1 message'
|
||||
} else {
|
||||
return `Messages list, ${total} messages, currently focused on message ${current} of ${total}`
|
||||
}
|
||||
})
|
||||
|
||||
const navigationHint = 'Use arrow keys to navigate, Page Up/Down to jump 10 messages, Home/End for first/last, Enter to select'
|
||||
|
||||
// Keyboard navigation
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (totalMessages.value === 0) return
|
||||
|
||||
let newIndex = focusedMessageIndex.value
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
newIndex = Math.max(0, focusedMessageIndex.value - 1)
|
||||
break
|
||||
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
newIndex = Math.min(totalMessages.value - 1, focusedMessageIndex.value + 1)
|
||||
break
|
||||
|
||||
case 'PageUp':
|
||||
event.preventDefault()
|
||||
newIndex = Math.max(0, focusedMessageIndex.value - 10)
|
||||
break
|
||||
|
||||
case 'PageDown':
|
||||
event.preventDefault()
|
||||
newIndex = Math.min(totalMessages.value - 1, focusedMessageIndex.value + 10)
|
||||
break
|
||||
|
||||
case 'Home':
|
||||
event.preventDefault()
|
||||
newIndex = 0
|
||||
break
|
||||
|
||||
case 'End':
|
||||
event.preventDefault()
|
||||
newIndex = totalMessages.value - 1
|
||||
break
|
||||
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
event.preventDefault()
|
||||
selectCurrentMessage()
|
||||
return
|
||||
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
if (newIndex !== focusedMessageIndex.value) {
|
||||
focusMessage(newIndex)
|
||||
}
|
||||
}
|
||||
|
||||
const focusMessage = (index: number) => {
|
||||
focusedMessageIndex.value = index
|
||||
nextTick(() => {
|
||||
const messageElement = containerRef.value?.querySelector(`[data-message-index="${index}"]`) as HTMLElement
|
||||
if (messageElement) {
|
||||
messageElement.focus()
|
||||
messageElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const selectCurrentMessage = () => {
|
||||
const currentMessage = allMessages.value[focusedMessageIndex.value]
|
||||
if (currentMessage) {
|
||||
emit('message-selected', currentMessage, focusedMessageIndex.value)
|
||||
}
|
||||
}
|
||||
|
||||
// Method to focus a specific message (for external use, like search results)
|
||||
const focusMessageById = (messageId: string | number) => {
|
||||
const index = allMessages.value.findIndex(msg => msg.id === messageId)
|
||||
if (index !== -1) {
|
||||
focusMessage(index)
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
nextTick(() => {
|
||||
if (containerRef.value) {
|
||||
containerRef.value.scrollTop = containerRef.value.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Watch for new messages and auto-scroll
|
||||
watch(() => [props.messages.length, props.unsentMessages.length], () => {
|
||||
// When new messages arrive, focus the last message and scroll to bottom
|
||||
if (totalMessages.value > 0) {
|
||||
focusedMessageIndex.value = totalMessages.value - 1
|
||||
}
|
||||
scrollToBottom()
|
||||
})
|
||||
|
||||
// Reset focus when messages change significantly
|
||||
watch(() => totalMessages.value, (newTotal) => {
|
||||
if (focusedMessageIndex.value >= newTotal) {
|
||||
focusedMessageIndex.value = Math.max(0, newTotal - 1)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
scrollToBottom()
|
||||
// Focus the last message on mount
|
||||
if (totalMessages.value > 0) {
|
||||
focusedMessageIndex.value = totalMessages.value - 1
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
scrollToBottom,
|
||||
focusMessageById
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.messages-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.messages-container:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.messages-container::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.messages-container::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.messages-container::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.messages-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.messages-container {
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
.messages-container:focus {
|
||||
outline-color: #60a5fa;
|
||||
}
|
||||
|
||||
.messages-container::-webkit-scrollbar-track {
|
||||
background: #1f2937;
|
||||
}
|
||||
|
||||
.messages-container::-webkit-scrollbar-thumb {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
.messages-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #6b7280;
|
||||
}
|
||||
}
|
||||
</style>
|
318
frontend-vue/src/components/chat/VoiceMessage.vue
Normal file
318
frontend-vue/src/components/chat/VoiceMessage.vue
Normal file
@@ -0,0 +1,318 @@
|
||||
<template>
|
||||
<div class="voice-message">
|
||||
<div class="voice-player">
|
||||
<button
|
||||
@click="togglePlayback"
|
||||
class="play-button"
|
||||
:disabled="loading"
|
||||
>
|
||||
<Icon :name="isPlaying ? 'pause' : 'play'" size="sm" />
|
||||
</button>
|
||||
|
||||
<div class="voice-info">
|
||||
<div class="voice-waveform">
|
||||
<div class="progress-bar">
|
||||
<div
|
||||
class="progress"
|
||||
:style="{ width: `${progress}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="voice-meta" aria-live="off">
|
||||
<span class="duration">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</span>
|
||||
<span class="file-size">{{ formatFileSize(file.file_size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="downloadVoice"
|
||||
class="download-button"
|
||||
title="Download"
|
||||
>
|
||||
<Icon name="download" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="voice-filename">
|
||||
{{ file.original_name }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onUnmounted } from 'vue'
|
||||
import { apiService } from '@/services/api'
|
||||
import Icon from '@/components/base/Icon.vue'
|
||||
import type { FileAttachment } from '@/types'
|
||||
|
||||
interface Props {
|
||||
file: FileAttachment
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const isPlaying = ref(false)
|
||||
const loading = ref(false)
|
||||
const currentTime = ref(0)
|
||||
const duration = ref(0)
|
||||
let audio: HTMLAudioElement | null = null
|
||||
|
||||
const audioUrl = computed(() => apiService.getFileUrl(props.file.file_path))
|
||||
|
||||
const progress = computed(() => {
|
||||
return duration.value > 0 ? (currentTime.value / duration.value) * 100 : 0
|
||||
})
|
||||
|
||||
const togglePlayback = async () => {
|
||||
if (!audio) {
|
||||
await initAudio()
|
||||
}
|
||||
|
||||
if (!audio) return
|
||||
|
||||
if (isPlaying.value) {
|
||||
audio.pause()
|
||||
} else {
|
||||
await audio.play()
|
||||
}
|
||||
}
|
||||
|
||||
const initAudio = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
audio = new Audio(audioUrl.value)
|
||||
|
||||
audio.addEventListener('loadedmetadata', () => {
|
||||
const audioDuration = audio!.duration
|
||||
duration.value = isFinite(audioDuration) && !isNaN(audioDuration) ? audioDuration : 0
|
||||
})
|
||||
|
||||
audio.addEventListener('timeupdate', () => {
|
||||
currentTime.value = audio!.currentTime
|
||||
})
|
||||
|
||||
audio.addEventListener('play', () => {
|
||||
isPlaying.value = true
|
||||
})
|
||||
|
||||
audio.addEventListener('pause', () => {
|
||||
isPlaying.value = false
|
||||
})
|
||||
|
||||
audio.addEventListener('ended', () => {
|
||||
isPlaying.value = false
|
||||
currentTime.value = 0
|
||||
})
|
||||
|
||||
await audio.load()
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load audio:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
if (!isFinite(seconds) || isNaN(seconds)) {
|
||||
return '0:00'
|
||||
}
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = Math.floor(seconds % 60)
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const downloadVoice = async () => {
|
||||
try {
|
||||
const response = await fetch(audioUrl.value)
|
||||
const blob = await response.blob()
|
||||
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = blobUrl
|
||||
link.download = props.file.original_name
|
||||
link.click()
|
||||
|
||||
// Clean up the blob URL after download
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 100)
|
||||
} catch (error) {
|
||||
console.error('Failed to download voice message:', error)
|
||||
// Fallback to direct link
|
||||
const link = document.createElement('a')
|
||||
link.href = audioUrl.value
|
||||
link.download = props.file.original_name
|
||||
link.target = '_blank'
|
||||
link.click()
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup on component unmount
|
||||
onUnmounted(() => {
|
||||
if (audio) {
|
||||
audio.pause()
|
||||
audio.src = ''
|
||||
audio = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.voice-message {
|
||||
margin: 0.5rem 0;
|
||||
max-width: 350px;
|
||||
}
|
||||
|
||||
.voice-player {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
background: #f3f4f6;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.play-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.play-button:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.play-button:disabled {
|
||||
background: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.voice-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.voice-waveform {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 4px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 100%;
|
||||
background: #3b82f6;
|
||||
transition: width 0.1s ease;
|
||||
}
|
||||
|
||||
.voice-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.duration {
|
||||
color: #374151;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.download-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.download-button:hover {
|
||||
background: #f9fafb;
|
||||
color: #374151;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.voice-filename {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.voice-player {
|
||||
background: #374151;
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
.progress {
|
||||
background: #60a5fa;
|
||||
}
|
||||
|
||||
.duration {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
|
||||
.file-size {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.download-button {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
.download-button:hover {
|
||||
background: #4b5563;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
border-color: #6b7280;
|
||||
}
|
||||
|
||||
.voice-filename {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
}
|
||||
</style>
|
Reference in New Issue
Block a user