Initial vue frontend

This commit is contained in:
2025-08-12 01:05:59 +02:00
parent 64e50027ca
commit 58e0c10b4e
70 changed files with 16958 additions and 0 deletions

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>