Files
notebrook-notes/frontend-vue/src/views/MainView.vue

484 lines
13 KiB
Vue

<template>
<div class="main-view">
<!-- Sidebar -->
<Sidebar
:channels="appStore.channels"
:current-channel-id="appStore.currentChannelId"
:unread-counts="unreadCounts"
@create-channel="showChannelDialog = true"
@select-channel="selectChannel"
@channel-info="handleChannelInfo"
@settings="showSettings = true"
/>
<!-- Main Content -->
<main class="main-content">
<div v-if="appStore.currentChannel" class="chat-container">
<!-- Chat Header -->
<ChatHeader
:channel-name="appStore.currentChannel.name"
@search="showSearchDialog = true"
/>
<!-- Messages -->
<MessagesContainer
:messages="appStore.currentMessages"
:unsent-messages="appStore.unsentMessagesForChannel"
ref="messagesContainer"
/>
<!-- Message Input -->
<MessageInput
@send-message="handleSendMessage"
@file-upload="showFileDialog = true"
@camera="showCameraDialog = true"
@voice="showVoiceDialog = true"
ref="messageInput"
/>
</div>
<div v-else class="no-channel">
<p>Select a channel to start chatting</p>
</div>
</main>
<!-- Dialogs -->
<BaseDialog v-model:show="showChannelDialog" title="Create Channel">
<CreateChannelDialog
@cancel="showChannelDialog = false"
@created="handleChannelCreated"
/>
</BaseDialog>
<BaseDialog v-model:show="showSettings" title="Settings">
<SettingsDialog @close="showSettings = false" />
</BaseDialog>
<BaseDialog v-model:show="showSearchDialog" title="Search Messages" size="lg">
<SearchDialog
@close="showSearchDialog = false"
@select-message="handleSelectMessage"
/>
</BaseDialog>
<BaseDialog v-model:show="showFileDialog" title="Upload Files" size="lg">
<FileUploadDialog
@cancel="showFileDialog = false"
@uploaded="showFileDialog = false"
/>
</BaseDialog>
<BaseDialog v-model:show="showVoiceDialog" title="Record Voice Message">
<VoiceRecordingDialog
@close="showVoiceDialog = false"
@sent="handleVoiceSent"
/>
</BaseDialog>
<BaseDialog v-model:show="showCameraDialog" title="Take Photo">
<CameraCaptureDialog
@close="showCameraDialog = false"
@sent="handleCameraSent"
/>
</BaseDialog>
<BaseDialog v-model:show="showChannelInfoDialog" title="Channel Settings">
<ChannelInfoDialog
v-if="selectedChannelForInfo"
:channel="selectedChannelForInfo"
@close="showChannelInfoDialog = false"
/>
</BaseDialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
import { useToastStore } from '@/stores/toast'
import { useOfflineSync } from '@/composables/useOfflineSync'
import { useWebSocket } from '@/composables/useWebSocket'
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
import { useAudio } from '@/composables/useAudio'
import { apiService } from '@/services/api'
import { syncService } from '@/services/sync'
// Components
import BaseDialog from '@/components/base/BaseDialog.vue'
import Sidebar from '@/components/sidebar/Sidebar.vue'
import ChatHeader from '@/components/chat/ChatHeader.vue'
import MessagesContainer from '@/components/chat/MessagesContainer.vue'
import MessageInput from '@/components/chat/MessageInput.vue'
import CreateChannelDialog from '@/components/dialogs/CreateChannelDialog.vue'
import SettingsDialog from '@/components/dialogs/SettingsDialog.vue'
import SearchDialog from '@/components/dialogs/SearchDialog.vue'
import FileUploadDialog from '@/components/dialogs/FileUploadDialog.vue'
import VoiceRecordingDialog from '@/components/dialogs/VoiceRecordingDialog.vue'
import CameraCaptureDialog from '@/components/dialogs/CameraCaptureDialog.vue'
import ChannelInfoDialog from '@/components/dialogs/ChannelInfoDialog.vue'
// Types
import type { ExtendedMessage, Channel } from '@/types'
const router = useRouter()
const appStore = useAppStore()
const authStore = useAuthStore()
const toastStore = useToastStore()
const { sendMessage: sendMessageOffline } = useOfflineSync()
const { playWater, playSent, playSound, speak, stopSpeaking, isSpeaking } = useAudio()
// Set up services - ensure token is properly set
if (authStore.token) {
apiService.setToken(authStore.token)
}
// Refs
const messagesContainer = ref()
const messageInput = ref()
// Dialog states
const showChannelDialog = ref(false)
const showChannelInfoDialog = ref(false)
const showSettings = ref(false)
const showSearchDialog = ref(false)
const showFileDialog = ref(false)
const showVoiceDialog = ref(false)
const showCameraDialog = ref(false)
// Channel info state
const selectedChannelForInfo = ref<Channel | null>(null)
// Mock unread counts (implement real logic later)
const unreadCounts = ref<Record<number, number>>({})
// Set up keyboard shortcuts
const { addShortcut } = useKeyboardShortcuts()
const setupKeyboardShortcuts = () => {
// Ctrl+Shift+S - Settings
addShortcut({
key: 's',
ctrlKey: true,
shiftKey: true,
handler: () => { showSettings.value = true }
})
// Ctrl+Shift+F - Search
addShortcut({
key: 'f',
ctrlKey: true,
shiftKey: true,
handler: () => { showSearchDialog.value = true }
})
// Ctrl+Shift+C - Channel selector focus
addShortcut({
key: 'c',
ctrlKey: true,
shiftKey: true,
handler: () => {
// Focus the first channel in the list
const firstChannelButton = document.querySelector('.channel-item button') as HTMLElement
if (firstChannelButton) {
firstChannelButton.focus()
toastStore.info('Channel selector focused')
}
}
})
// Ctrl+Shift+X - Channel info
addShortcut({
key: 'x',
ctrlKey: true,
shiftKey: true,
handler: () => {
if (appStore.currentChannel) {
toastStore.info(`Channel: ${appStore.currentChannel.name} (${appStore.currentMessages.length} messages)`)
} else {
toastStore.info('No channel selected')
}
}
})
// Ctrl+Shift+V - Voice message
addShortcut({
key: 'v',
ctrlKey: true,
shiftKey: true,
handler: () => {
if (appStore.currentChannelId) {
showVoiceDialog.value = true
} else {
toastStore.info('Select a channel first')
}
}
})
// Space - Focus message input
addShortcut({
key: ' ',
handler: () => { messageInput.value?.focus() }
})
// Ctrl+Shift+T - Toggle TTS
addShortcut({
key: 't',
ctrlKey: true,
shiftKey: true,
handler: () => {
appStore.updateSettings({ ttsEnabled: !appStore.settings.ttsEnabled })
toastStore.info(`TTS ${appStore.settings.ttsEnabled ? 'enabled' : 'disabled'}`)
}
})
// Escape - Stop speaking
addShortcut({
key: 'escape',
handler: () => {
if (isSpeaking.value) {
stopSpeaking()
toastStore.info('Speech stopped')
}
}
})
// Alt+Numbers - Announce last N messages
for (let i = 1; i <= 9; i++) {
addShortcut({
key: i.toString(),
altKey: true,
handler: () => announceLastMessage(i)
})
}
// Alt+0 - Announce last 10 messages
addShortcut({
key: '0',
altKey: true,
handler: () => announceLastMessage(10)
})
}
const selectChannel = async (channelId: number) => {
console.log('Selecting channel:', channelId)
await appStore.setCurrentChannel(channelId)
// Try to sync messages for this channel
try {
await syncService.syncChannelMessages(channelId)
console.log('Channel messages synced')
} catch (error) {
console.log('Failed to sync channel messages, using local cache')
}
scrollToBottom()
}
const handleSendMessage = async (content: string) => {
if (!appStore.currentChannelId) return
console.log('Sending message:', content, 'to channel:', appStore.currentChannelId)
try {
await syncService.sendMessage(appStore.currentChannelId, content)
playSent()
scrollToBottom()
toastStore.success('Message sent')
} catch (error) {
console.error('Failed to send message:', error)
playWater() // Still play sound for queued message
scrollToBottom()
toastStore.error('Message queued for sending when online')
}
}
const handleSelectMessage = async (message: ExtendedMessage) => {
showSearchDialog.value = false
// Switch to the correct channel if needed
if (message.channel_id !== appStore.currentChannelId) {
await selectChannel(message.channel_id)
}
// Wait for the DOM to update, then focus the specific message
await nextTick()
// Use the MessagesContainer's focusMessageById method for proper roving tabindex
if (messagesContainer.value?.focusMessageById) {
messagesContainer.value.focusMessageById(message.id)
// Add visual highlight
await nextTick()
const messageElement = document.querySelector(`[data-message-id="${message.id}"]`)
if (messageElement) {
messageElement.classList.add('message--highlighted')
setTimeout(() => {
messageElement.classList.remove('message--highlighted')
}, 2000)
}
} else {
// Fallback to scrolling to bottom if method not available
scrollToBottom()
}
}
const formatTime = (timestamp: string): string => {
return new Date(timestamp).toLocaleTimeString()
}
const handleVoiceSent = () => {
// Voice message was sent successfully
showVoiceDialog.value = false
scrollToBottom()
playSent()
}
const handleCameraSent = () => {
// Photo was sent successfully
showCameraDialog.value = false
scrollToBottom()
playSent()
}
const announceLastMessage = (position: number) => {
const messages = appStore.currentMessages
if (!messages || messages.length === 0) {
toastStore.info('There are no messages in this channel right now')
return
}
const messageIndex = messages.length - position
if (messageIndex < 0) {
toastStore.info('No message is available in this position')
return
}
const message = messages[messageIndex]
const timeStr = formatTime(message.created_at)
const announcement = `${message.content}; ${timeStr}`
toastStore.info(announcement)
// Also speak if TTS is enabled
if (appStore.settings.ttsEnabled) {
speak(announcement)
}
}
const scrollToBottom = () => {
messagesContainer.value?.scrollToBottom()
}
const handleChannelCreated = async (channelId: number) => {
showChannelDialog.value = false
await selectChannel(channelId)
}
const handleChannelInfo = (channel: Channel) => {
selectedChannelForInfo.value = channel
showChannelInfoDialog.value = true
}
const isUnsentMessage = (messageId: string | number): boolean => {
return typeof messageId === 'string' && messageId.startsWith('unsent_')
}
// Initialize
onMounted(async () => {
// 1. Load saved state first (offline-first)
console.log('Loading local state...')
await appStore.loadState()
console.log('Local state loaded. Channels:', appStore.channels.length, 'Current channel:', appStore.currentChannelId, 'Unsent messages:', appStore.unsentMessages.length)
// 2. Try to sync with server (when online)
try {
console.log('Syncing with server...')
await syncService.fullSync()
toastStore.success('Synced with server')
} catch (error) {
console.log('Failed to sync with server, working offline with cached data')
if (appStore.channels.length === 0) {
toastStore.error('No internet connection and no cached data available')
} else {
toastStore.info('Working offline with cached data')
}
}
// 3. WebSocket connection (will gracefully fail if offline)
useWebSocket()
// 4. Set up keyboard shortcuts
setupKeyboardShortcuts()
// 5. Auto-select first channel if none selected and we have channels
if (!appStore.currentChannelId && appStore.channels.length > 0) {
await selectChannel(appStore.channels[0].id)
}
// 6. Set up periodic sync for unsent messages
const syncInterval = setInterval(async () => {
if (appStore.unsentMessages.length > 0) {
try {
console.log(`Attempting to sync ${appStore.unsentMessages.length} unsent messages`)
await syncService.retryUnsentMessages()
} catch (error) {
console.log('Background sync failed, will try again later')
}
}
}, 30000) // Every 30 seconds
// Cleanup interval on unmount
const cleanup = () => clearInterval(syncInterval)
window.addEventListener('beforeunload', cleanup)
})
</script>
<style scoped>
.main-view {
display: flex;
height: 100vh;
background: #ffffff;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.chat-container {
display: flex;
flex-direction: column;
height: 100%;
}
.no-channel {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #6b7280;
font-size: 1.125rem;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.main-view {
background: #111827;
}
.no-channel {
color: rgba(255, 255, 255, 0.6);
}
}
/* Responsive design */
@media (max-width: 768px) {
.main-view {
flex-direction: column;
}
}
</style>