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,467 @@
import { ref, computed, readonly } from 'vue'
import { useAppStore } from '@/stores/app'
interface AudioRecording {
blob: Blob | null
duration: number
isRecording: boolean
isPlaying: boolean
currentTime: number
}
// Global audio state to ensure singleton behavior
let audioSystemInitialized = false
let soundsLoaded = false
let globalAudioContext: AudioContext | null = null
let globalSoundBuffers = new Map<string, AudioBuffer>()
let globalWaterSounds: AudioBuffer[] = []
let globalSentSounds: AudioBuffer[] = []
export function useAudio() {
const appStore = useAppStore()
// Audio Context (use global instance)
const audioContext = ref<AudioContext | null>(globalAudioContext)
// Sound buffers (use global arrays)
const soundBuffers = ref<Map<string, AudioBuffer>>(globalSoundBuffers)
const waterSounds = ref<AudioBuffer[]>(globalWaterSounds)
const sentSounds = ref<AudioBuffer[]>(globalSentSounds)
// Recording state
const recording = ref<AudioRecording>({
blob: null,
duration: 0,
isRecording: false,
isPlaying: false,
currentTime: 0
})
// Media recorder
let mediaRecorder: MediaRecorder | null = null
let recordingChunks: Blob[] = []
let recordingStartTime: number = 0
let recordingInterval: number | null = null
// Text-to-speech state
const isSpeaking = ref(false)
const availableVoices = ref<SpeechSynthesisVoice[]>([])
const selectedVoice = ref<SpeechSynthesisVoice | null>(null)
// Initialize audio context
const initAudioContext = async () => {
if (!globalAudioContext) {
globalAudioContext = new AudioContext()
audioContext.value = globalAudioContext
}
if (globalAudioContext.state === 'suspended') {
try {
await globalAudioContext.resume()
} catch (error) {
console.warn('AudioContext resume failed, user interaction required:', error)
}
}
}
// Load a single sound file
const loadSound = async (url: string): Promise<AudioBuffer | null> => {
try {
if (!audioContext.value) {
await initAudioContext()
}
if (!audioContext.value) {
// AudioContext creation failed (probably no user interaction yet)
return null
}
const response = await fetch(url)
const arrayBuffer = await response.arrayBuffer()
return await audioContext.value.decodeAudioData(arrayBuffer)
} catch (error) {
console.warn(`Failed to load sound ${url}:`, error)
return null
}
}
// Load all sound files
const loadAllSounds = async () => {
if (soundsLoaded) {
console.log('Sounds already loaded, skipping...')
return
}
try {
console.log('Starting to load all sounds...')
soundsLoaded = true
// Load basic sounds
const basicSounds = {
intro: '/sounds/intro.wav',
login: '/sounds/login.wav',
copy: '/sounds/copy.wav',
uploadFailed: '/sounds/uploadfail.wav'
}
for (const [name, url] of Object.entries(basicSounds)) {
const buffer = await loadSound(url)
if (buffer) {
globalSoundBuffers.set(name, buffer)
soundBuffers.value.set(name, buffer)
}
}
// Load water sounds (1-10)
console.log('Loading water sounds...')
for (let i = 1; i <= 10; i++) {
const buffer = await loadSound(`/sounds/water${i}.wav`)
if (buffer) {
globalWaterSounds.push(buffer)
waterSounds.value.push(buffer)
console.log(`Loaded water sound ${i}`)
} else {
console.warn(`Failed to load water sound ${i}`)
}
}
console.log(`Water sounds loaded: ${globalWaterSounds.length}/10, reactive: ${waterSounds.value.length}/10`)
// Load sent sounds (1-6)
for (let i = 1; i <= 6; i++) {
const buffer = await loadSound(`/sounds/sent${i}.wav`)
if (buffer) {
globalSentSounds.push(buffer)
sentSounds.value.push(buffer)
}
}
console.log('All sounds loaded and ready to play')
} catch (error) {
console.error('Error loading sounds:', error)
}
}
// Play a sound buffer
const playSoundBuffer = async (buffer: AudioBuffer) => {
if (!appStore.settings.soundEnabled) return
try {
await initAudioContext()
if (!globalAudioContext) {
console.error('AudioContext not initialized')
return
}
const source = globalAudioContext.createBufferSource()
source.buffer = buffer
source.connect(globalAudioContext.destination)
source.start(0)
} catch (error) {
console.error('Error playing sound:', error)
}
}
// Play specific sounds
const playSound = async (name: string) => {
const buffer = globalSoundBuffers.get(name)
if (buffer) {
await playSoundBuffer(buffer)
} else {
console.warn(`Sound ${name} not loaded`)
}
}
const playWater = async () => {
console.log(`playWater called - global: ${globalWaterSounds.length}, reactive: ${waterSounds.value.length} water sounds available`)
if (globalWaterSounds.length > 0) {
const randomIndex = Math.floor(Math.random() * globalWaterSounds.length)
await playSoundBuffer(globalWaterSounds[randomIndex])
} else {
console.warn('Water sounds not loaded - trying to load them now')
if (globalAudioContext) {
await loadAllSounds()
if (globalWaterSounds.length > 0) {
const randomIndex = Math.floor(Math.random() * globalWaterSounds.length)
await playSoundBuffer(globalWaterSounds[randomIndex])
}
}
}
}
const playSent = async () => {
if (globalSentSounds.length > 0) {
const randomIndex = Math.floor(Math.random() * globalSentSounds.length)
await playSoundBuffer(globalSentSounds[randomIndex])
} else {
console.warn('Sent sounds not loaded')
}
}
// Voice recording
const startRecording = async (): Promise<boolean> => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: true
}
})
mediaRecorder = new MediaRecorder(stream, {
mimeType: 'audio/webm;codecs=opus'
})
recordingChunks = []
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
recordingChunks.push(event.data)
}
}
mediaRecorder.onstop = () => {
const blob = new Blob(recordingChunks, { type: 'audio/webm;codecs=opus' })
recording.value.blob = blob
recording.value.isRecording = false
if (recordingInterval) {
clearInterval(recordingInterval)
recordingInterval = null
}
// Stop all tracks to release microphone
stream.getTracks().forEach(track => track.stop())
}
mediaRecorder.start()
recording.value.isRecording = true
recording.value.duration = 0
recordingStartTime = Date.now()
// Update duration every 100ms
recordingInterval = setInterval(() => {
recording.value.duration = (Date.now() - recordingStartTime) / 1000
}, 100)
return true
} catch (error) {
console.error('Failed to start recording:', error)
recording.value.isRecording = false
return false
}
}
const stopRecording = () => {
if (mediaRecorder && recording.value.isRecording) {
mediaRecorder.stop()
}
}
const playRecording = async () => {
if (!recording.value.blob) return false
try {
const audio = new Audio(URL.createObjectURL(recording.value.blob))
recording.value.isPlaying = true
recording.value.currentTime = 0
audio.ontimeupdate = () => {
recording.value.currentTime = audio.currentTime
}
audio.onended = () => {
recording.value.isPlaying = false
recording.value.currentTime = 0
URL.revokeObjectURL(audio.src)
}
await audio.play()
return true
} catch (error) {
console.error('Failed to play recording:', error)
recording.value.isPlaying = false
return false
}
}
const clearRecording = () => {
if (recording.value.blob) {
URL.revokeObjectURL(URL.createObjectURL(recording.value.blob))
}
recording.value.blob = null
recording.value.duration = 0
recording.value.isPlaying = false
recording.value.currentTime = 0
}
// Text-to-speech functions
const loadVoices = () => {
const voices = speechSynthesis.getVoices()
availableVoices.value = voices
// Select default voice (prefer English voices)
if (!selectedVoice.value && voices.length > 0) {
const englishVoice = voices.find(voice => voice.lang.startsWith('en'))
selectedVoice.value = englishVoice || voices[0]
}
}
const speak = (text: string, options: { rate?: number, pitch?: number, volume?: number } = {}) => {
if (!appStore.settings.ttsEnabled) return Promise.resolve()
return new Promise<void>((resolve, reject) => {
if ('speechSynthesis' in window) {
// Stop any current speech
speechSynthesis.cancel()
const utterance = new SpeechSynthesisUtterance(text)
// Set voice if available
if (selectedVoice.value) {
utterance.voice = selectedVoice.value
}
// Apply options
utterance.rate = options.rate || appStore.settings.ttsRate || 1
utterance.pitch = options.pitch || appStore.settings.ttsPitch || 1
utterance.volume = options.volume || appStore.settings.ttsVolume || 1
utterance.onstart = () => {
isSpeaking.value = true
}
utterance.onend = () => {
isSpeaking.value = false
resolve()
}
utterance.onerror = (event) => {
isSpeaking.value = false
console.error('Speech synthesis error:', event.error)
reject(new Error(`Speech synthesis failed: ${event.error}`))
}
speechSynthesis.speak(utterance)
} else {
reject(new Error('Speech synthesis not supported'))
}
})
}
const stopSpeaking = () => {
if ('speechSynthesis' in window) {
speechSynthesis.cancel()
isSpeaking.value = false
}
}
const setVoice = (voice: SpeechSynthesisVoice) => {
selectedVoice.value = voice
appStore.updateSettings({ selectedVoiceURI: voice.voiceURI })
}
// Announce message for accessibility
const announceMessage = async (content: string, channel?: string) => {
if (!appStore.settings.ttsEnabled) return
let textToSpeak = content
if (channel) {
textToSpeak = `New message in ${channel}: ${content}`
}
try {
await speak(textToSpeak)
} catch (error) {
console.error('Failed to announce message:', error)
}
}
// Computed
const canRecord = computed(() => {
return navigator.mediaDevices && navigator.mediaDevices.getUserMedia
})
const recordingDurationFormatted = computed(() => {
const duration = recording.value.duration
const minutes = Math.floor(duration / 60)
const seconds = Math.floor(duration % 60)
return `${minutes}:${seconds.toString().padStart(2, '0')}`
})
// Initialize audio on first user interaction
const initAudioOnUserGesture = async () => {
if (!audioContext.value || audioContext.value.state === 'suspended') {
await initAudioContext()
}
}
// Initialize audio system (only once)
const initializeAudioSystem = () => {
if (!audioSystemInitialized) {
audioSystemInitialized = true
// Set up user gesture listeners to initialize audio and load sounds
const initializeAudio = async () => {
console.log('User interaction detected, initializing audio system...')
await initAudioOnUserGesture()
await loadAllSounds() // Load sounds after user interaction
console.log('Audio system initialized')
document.removeEventListener('click', initializeAudio)
document.removeEventListener('keydown', initializeAudio)
}
document.addEventListener('click', initializeAudio, { once: true })
document.addEventListener('keydown', initializeAudio, { once: true })
// Initialize voices for speech synthesis
if ('speechSynthesis' in window) {
loadVoices()
// Voices may not be immediately available
speechSynthesis.addEventListener('voiceschanged', loadVoices)
// Restore selected voice from settings
if (appStore.settings.selectedVoiceURI) {
const voices = speechSynthesis.getVoices()
const savedVoice = voices.find(v => v.voiceURI === appStore.settings.selectedVoiceURI)
if (savedVoice) {
selectedVoice.value = savedVoice
}
}
}
}
}
// Initialize audio system when composable is first used
initializeAudioSystem()
return {
// State
recording,
canRecord,
recordingDurationFormatted,
isSpeaking: readonly(isSpeaking),
availableVoices: readonly(availableVoices),
selectedVoice: readonly(selectedVoice),
// Audio playback
playSound,
playWater,
playSent,
// Voice recording
startRecording,
stopRecording,
playRecording,
clearRecording,
// Text-to-speech
speak,
stopSpeaking,
setVoice,
announceMessage,
// Audio context
initAudioContext
}
}

View File

@@ -0,0 +1,93 @@
import { onMounted, onUnmounted, ref, readonly } from 'vue'
interface ShortcutConfig {
key: string
ctrlKey?: boolean
shiftKey?: boolean
altKey?: boolean
metaKey?: boolean
handler: () => void
preventDefault?: boolean
}
export function useKeyboardShortcuts() {
const shortcuts = ref<Map<string, ShortcutConfig>>(new Map())
const isListening = ref(false)
const getShortcutKey = (config: ShortcutConfig): string => {
const parts = []
if (config.ctrlKey) parts.push('ctrl')
if (config.shiftKey) parts.push('shift')
if (config.altKey) parts.push('alt')
if (config.metaKey) parts.push('meta')
parts.push(config.key.toLowerCase())
return parts.join('+')
}
const handleKeydown = (event: KeyboardEvent) => {
// Skip shortcuts when focused on input/textarea elements
const target = event.target as HTMLElement
if (target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA') {
return
}
const config: ShortcutConfig = {
key: event.key.toLowerCase(),
ctrlKey: event.ctrlKey,
shiftKey: event.shiftKey,
altKey: event.altKey,
metaKey: event.metaKey,
handler: () => {}
}
const shortcutKey = getShortcutKey(config)
const shortcut = shortcuts.value.get(shortcutKey)
if (shortcut) {
if (shortcut.preventDefault !== false) {
event.preventDefault()
}
shortcut.handler()
}
}
const addShortcut = (config: ShortcutConfig) => {
const key = getShortcutKey(config)
shortcuts.value.set(key, config)
}
const removeShortcut = (config: Omit<ShortcutConfig, 'handler'>) => {
const key = getShortcutKey(config as ShortcutConfig)
shortcuts.value.delete(key)
}
const startListening = () => {
if (!isListening.value) {
document.addEventListener('keydown', handleKeydown)
isListening.value = true
}
}
const stopListening = () => {
if (isListening.value) {
document.removeEventListener('keydown', handleKeydown)
isListening.value = false
}
}
onMounted(() => {
startListening()
})
onUnmounted(() => {
stopListening()
})
return {
addShortcut,
removeShortcut,
startListening,
stopListening,
isListening
}
}

View File

@@ -0,0 +1,178 @@
import { ref, onMounted, onUnmounted, readonly } from 'vue'
import { useAppStore } from '@/stores/app'
import { useToastStore } from '@/stores/toast'
import { apiService } from '@/services/api'
import type { UnsentMessage } from '@/types'
export function useOfflineSync() {
const appStore = useAppStore()
const toastStore = useToastStore()
const isOnline = ref(navigator.onLine)
const isSyncing = ref(false)
let syncInterval: number | null = null
// Monitor online status
const updateOnlineStatus = () => {
const wasOnline = isOnline.value
isOnline.value = navigator.onLine
if (!wasOnline && isOnline.value) {
toastStore.success('Back online - syncing data...')
syncUnsentMessages()
} else if (wasOnline && !isOnline.value) {
toastStore.info('You are offline - messages will be queued')
}
}
// Sync unsent messages when online
const syncUnsentMessages = async () => {
if (!isOnline.value || isSyncing.value || appStore.unsentMessages.length === 0) {
return
}
isSyncing.value = true
const failedMessages: UnsentMessage[] = []
for (const unsentMessage of appStore.unsentMessages) {
try {
const response = await apiService.createMessage(
unsentMessage.channelId,
unsentMessage.content
)
// Message sent successfully - remove from unsent queue
appStore.removeUnsentMessage(unsentMessage.id)
// Add to messages (will be handled by WebSocket event too)
appStore.addMessage(response)
} catch (error) {
console.error('Failed to sync message:', error)
// Increment retry count (create mutable copy)
const mutableMessage = { ...unsentMessage, retries: unsentMessage.retries + 1 }
// If too many retries, give up
if (mutableMessage.retries >= 3) {
toastStore.error(`Failed to send message after 3 attempts: "${unsentMessage.content.substring(0, 50)}..."`)
appStore.removeUnsentMessage(unsentMessage.id)
} else {
failedMessages.push(mutableMessage)
}
}
}
// Update unsent messages with failed ones
if (failedMessages.length > 0) {
toastStore.error(`${failedMessages.length} messages failed to sync. Will retry...`)
} else if (appStore.unsentMessages.length > 0) {
toastStore.success('All offline messages synced!')
}
isSyncing.value = false
await appStore.saveState()
}
// Queue message for sending when offline
const queueMessage = async (channelId: number, content: string): Promise<string> => {
const unsentMessage: UnsentMessage = {
id: `unsent_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
channelId,
content,
timestamp: Date.now(),
retries: 0
}
appStore.addUnsentMessage(unsentMessage)
await appStore.saveState()
// Try to send immediately if online
if (isOnline.value) {
syncUnsentMessages()
}
return unsentMessage.id
}
// Send message (online or queue for offline)
const sendMessage = async (channelId: number, content: string): Promise<boolean> => {
if (isOnline.value) {
try {
const response = await apiService.createMessage(channelId, content)
appStore.addMessage(response)
return true
} catch (error) {
console.error('Failed to send message online:', error)
// Fall back to queuing
await queueMessage(channelId, content)
toastStore.error('Failed to send message - queued for later')
return false
}
} else {
await queueMessage(channelId, content)
toastStore.info('Message queued for sending when online')
return false
}
}
// Auto-save state periodically
const startAutoSave = () => {
if (syncInterval) clearInterval(syncInterval)
syncInterval = setInterval(async () => {
try {
await appStore.saveState()
// Try to sync unsent messages if online
if (isOnline.value && appStore.unsentMessages.length > 0) {
syncUnsentMessages()
}
} catch (error) {
console.error('Auto-save failed:', error)
}
}, 10000) // Save every 10 seconds
}
const stopAutoSave = () => {
if (syncInterval) {
clearInterval(syncInterval)
syncInterval = null
}
}
// Handle beforeunload to save state
const handleBeforeUnload = () => {
appStore.saveState()
}
onMounted(() => {
// Add event listeners
window.addEventListener('online', updateOnlineStatus)
window.addEventListener('offline', updateOnlineStatus)
window.addEventListener('beforeunload', handleBeforeUnload)
// Start auto-save
startAutoSave()
// Initial sync if online and has unsent messages
if (isOnline.value && appStore.unsentMessages.length > 0) {
syncUnsentMessages()
}
})
onUnmounted(() => {
// Clean up
window.removeEventListener('online', updateOnlineStatus)
window.removeEventListener('offline', updateOnlineStatus)
window.removeEventListener('beforeunload', handleBeforeUnload)
stopAutoSave()
})
return {
isOnline,
isSyncing,
sendMessage,
syncUnsentMessages,
queueMessage
}
}

View File

@@ -0,0 +1,152 @@
import { onMounted, onUnmounted } from 'vue'
import { websocketService } from '@/services/websocket'
import { useAppStore } from '@/stores/app'
import { useToastStore } from '@/stores/toast'
import { useAudio } from '@/composables/useAudio'
import type { Channel, ExtendedMessage, FileAttachment } from '@/types'
export function useWebSocket() {
const appStore = useAppStore()
const toastStore = useToastStore()
const { announceMessage } = useAudio()
const handleMessageCreated = (data: any) => {
console.log('WebSocket: Message created event received:', data)
console.log('Original content:', JSON.stringify(data.content))
// Transform the data to match our expected format
const message: ExtendedMessage = {
id: data.id,
channel_id: parseInt(data.channelId), // Convert channelId string to channel_id number
content: data.content,
created_at: data.createdAt || new Date().toISOString(),
file_id: data.fileId
}
console.log('WebSocket: Transformed message:', message)
console.log('Transformed content:', JSON.stringify(message.content))
appStore.addMessage(message)
// Announce new message for accessibility
const channel = appStore.channels.find(c => c.id === message.channel_id)
if (channel && appStore.settings.ttsEnabled) {
announceMessage(message.content, channel.name)
}
}
const handleMessageUpdated = (data: { id: string, content: string }) => {
appStore.updateMessage(parseInt(data.id), { content: data.content })
}
const handleMessageDeleted = (data: { id: string }) => {
appStore.removeMessage(parseInt(data.id))
}
const handleFileUploaded = (data: FileAttachment) => {
// Find the message and add the file to it
const channelMessages = appStore.messages[data.channel_id] || []
const messageIndex = channelMessages.findIndex(m => m.id === data.message_id)
if (messageIndex !== -1) {
const message = channelMessages[messageIndex]
const updatedMessage = {
...message,
files: [...(message.files || []), data]
}
appStore.updateMessage(message.id, updatedMessage)
}
}
const handleChannelCreated = (data: { channel: Channel }) => {
appStore.addChannel(data.channel)
toastStore.success(`Channel "${data.channel.name}" created`)
}
const handleChannelDeleted = (data: { id: string }) => {
const channelId = parseInt(data.id)
const channel = appStore.channels.find(c => c.id === channelId)
appStore.removeChannel(channelId)
if (channel) {
toastStore.info(`Channel "${channel.name}" was deleted`)
}
}
const handleChannelMerged = (data: { channelId: string, targetChannelId: string }) => {
const sourceChannelId = parseInt(data.channelId)
const targetChannelId = parseInt(data.targetChannelId)
const sourceChannel = appStore.channels.find(c => c.id === sourceChannelId)
const targetChannel = appStore.channels.find(c => c.id === targetChannelId)
if (sourceChannel && targetChannel) {
// Merge messages from source to target
const sourceMessages = [...(appStore.messages[sourceChannelId] || [])]
const targetMessages = [...(appStore.messages[targetChannelId] || [])]
appStore.setMessages(targetChannelId, [...targetMessages, ...sourceMessages])
// Remove source channel
appStore.removeChannel(sourceChannelId)
toastStore.success(`Channel "${sourceChannel.name}" merged into "${targetChannel.name}"`)
}
}
const handleChannelUpdated = (data: { id: string, name: string }) => {
// Update channel in store (if we implement channel renaming)
const channelId = parseInt(data.id)
const channels = [...appStore.channels]
const channelIndex = channels.findIndex(c => c.id === channelId)
if (channelIndex !== -1) {
channels[channelIndex] = { ...channels[channelIndex], name: data.name }
appStore.setChannels(channels)
}
}
const setupEventHandlers = () => {
websocketService.on('message-created', handleMessageCreated)
websocketService.on('message-updated', handleMessageUpdated)
websocketService.on('message-deleted', handleMessageDeleted)
websocketService.on('file-uploaded', handleFileUploaded)
websocketService.on('channel-created', handleChannelCreated)
websocketService.on('channel-deleted', handleChannelDeleted)
websocketService.on('channel-merged', handleChannelMerged)
websocketService.on('channel-updated', handleChannelUpdated)
websocketService.on('connected', () => {
console.log('WebSocket connected successfully')
toastStore.success('Connected to server')
})
websocketService.on('disconnected', () => {
toastStore.error('Disconnected from server')
})
websocketService.on('error', () => {
toastStore.error('WebSocket connection error')
})
}
const removeEventHandlers = () => {
websocketService.off('message-created', handleMessageCreated)
websocketService.off('message-updated', handleMessageUpdated)
websocketService.off('message-deleted', handleMessageDeleted)
websocketService.off('file-uploaded', handleFileUploaded)
websocketService.off('channel-created', handleChannelCreated)
websocketService.off('channel-deleted', handleChannelDeleted)
websocketService.off('channel-merged', handleChannelMerged)
websocketService.off('channel-updated', handleChannelUpdated)
}
onMounted(() => {
setupEventHandlers()
websocketService.connect()
})
onUnmounted(() => {
removeEventHandlers()
websocketService.disconnect()
})
return {
connect: () => websocketService.connect(),
disconnect: () => websocketService.disconnect(),
isConnected: () => websocketService.isConnected
}
}