Initial vue frontend
This commit is contained in:
153
frontend-vue/src/services/api.ts
Normal file
153
frontend-vue/src/services/api.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import type { Channel, Message, ExtendedMessage, FileAttachment } from '@/types'
|
||||
|
||||
class ApiService {
|
||||
private baseUrl = import.meta.env.DEV ? 'http://localhost:3000' : ''
|
||||
private token = ''
|
||||
|
||||
setToken(token: string) {
|
||||
this.token = token
|
||||
console.log('API service token set:', token ? `${token.substring(0, 10)}...` : 'null')
|
||||
}
|
||||
|
||||
private getHeaders(): HeadersInit {
|
||||
return {
|
||||
'Authorization': this.token,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
private getFormHeaders(): HeadersInit {
|
||||
return {
|
||||
'Authorization': this.token
|
||||
}
|
||||
}
|
||||
|
||||
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
const url = `${this.baseUrl}${endpoint}`
|
||||
const headers = {
|
||||
...this.getHeaders(),
|
||||
...options.headers
|
||||
}
|
||||
|
||||
console.log('Making API request to:', url, 'with headers:', headers)
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('API request failed:', response.status, response.statusText)
|
||||
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// Authentication
|
||||
async checkToken(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/check-token`, {
|
||||
headers: { Authorization: this.token }
|
||||
})
|
||||
return response.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Channels
|
||||
async getChannels(): Promise<{ channels: Channel[] }> {
|
||||
return this.request('/channels')
|
||||
}
|
||||
|
||||
async createChannel(name: string): Promise<Channel> {
|
||||
return this.request('/channels', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name })
|
||||
})
|
||||
}
|
||||
|
||||
async updateChannel(channelId: number, name: string): Promise<{ message: string }> {
|
||||
return this.request(`/channels/${channelId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name })
|
||||
})
|
||||
}
|
||||
|
||||
async deleteChannel(channelId: number): Promise<{ message: string }> {
|
||||
return this.request(`/channels/${channelId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
async mergeChannels(sourceChannelId: number, targetChannelId: number): Promise<{ message: string }> {
|
||||
return this.request(`/channels/${sourceChannelId}/merge`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ targetChannelId: targetChannelId.toString() })
|
||||
})
|
||||
}
|
||||
|
||||
// Messages
|
||||
async getMessages(channelId: number): Promise<{ messages: ExtendedMessage[] }> {
|
||||
return this.request(`/channels/${channelId}/messages`)
|
||||
}
|
||||
|
||||
async createMessage(channelId: number, content: string): Promise<Message> {
|
||||
return this.request(`/channels/${channelId}/messages`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content })
|
||||
})
|
||||
}
|
||||
|
||||
async updateMessage(channelId: number, messageId: number, content: string): Promise<{ id: string, content: string }> {
|
||||
return this.request(`/channels/${channelId}/messages/${messageId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ content })
|
||||
})
|
||||
}
|
||||
|
||||
async deleteMessage(channelId: number, messageId: number): Promise<{ message: string }> {
|
||||
return this.request(`/channels/${channelId}/messages/${messageId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
// Files
|
||||
async uploadFile(channelId: number, messageId: number, file: File): Promise<FileAttachment> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/channels/${channelId}/messages/${messageId}/files`, {
|
||||
method: 'POST',
|
||||
headers: this.getFormHeaders(),
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`File upload failed: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async getFiles(channelId: number, messageId: number): Promise<{ files: FileAttachment[] }> {
|
||||
return this.request(`/channels/${channelId}/messages/${messageId}/files`)
|
||||
}
|
||||
|
||||
// Search
|
||||
async search(query: string, channelId?: number): Promise<{ results: Message[] }> {
|
||||
const params = new URLSearchParams({ query })
|
||||
if (channelId) {
|
||||
params.append('channelId', channelId.toString())
|
||||
}
|
||||
return this.request(`/search?${params.toString()}`)
|
||||
}
|
||||
|
||||
// File URL helper
|
||||
getFileUrl(filePath: string): string {
|
||||
return `${this.baseUrl}/uploads/${filePath.replace(/^.*\/uploads\//, '')}`
|
||||
}
|
||||
}
|
||||
|
||||
export const apiService = new ApiService()
|
206
frontend-vue/src/services/sync.ts
Normal file
206
frontend-vue/src/services/sync.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { apiService } from './api'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import type { ExtendedMessage, UnsentMessage } from '@/types'
|
||||
|
||||
export class SyncService {
|
||||
private getAppStore() {
|
||||
return useAppStore()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync messages for a channel: merge server data with local data
|
||||
*/
|
||||
async syncChannelMessages(channelId: number): Promise<void> {
|
||||
try {
|
||||
console.log(`Syncing messages for channel ${channelId}`)
|
||||
|
||||
const appStore = this.getAppStore()
|
||||
|
||||
// Get server messages
|
||||
const serverResponse = await apiService.getMessages(channelId)
|
||||
const serverMessages = serverResponse.messages
|
||||
|
||||
// Get local messages
|
||||
const localMessages = appStore.messages[channelId] || []
|
||||
|
||||
console.log(`Server has ${serverMessages.length} messages, local has ${localMessages.length} messages`)
|
||||
|
||||
// Merge messages using a simple strategy:
|
||||
// 1. Create a map of all messages by ID
|
||||
// 2. Server messages take precedence (they may have been updated)
|
||||
// 3. Keep local messages that don't exist on server (may be unsent)
|
||||
|
||||
const messageMap = new Map<number, ExtendedMessage>()
|
||||
|
||||
// Add local messages first
|
||||
localMessages.forEach(msg => {
|
||||
if (typeof msg.id === 'number') {
|
||||
messageMap.set(msg.id, msg)
|
||||
}
|
||||
})
|
||||
|
||||
// Add/update with server messages (server wins for conflicts)
|
||||
serverMessages.forEach(msg => {
|
||||
// Transform server message format to match our types
|
||||
const transformedMsg: ExtendedMessage = {
|
||||
id: msg.id,
|
||||
channel_id: msg.channelId || msg.channel_id,
|
||||
content: msg.content,
|
||||
created_at: msg.createdAt || msg.created_at,
|
||||
file_id: msg.fileId || msg.file_id,
|
||||
// Map the flattened file fields from backend
|
||||
fileId: msg.fileId,
|
||||
filePath: msg.filePath,
|
||||
fileType: msg.fileType,
|
||||
fileSize: msg.fileSize,
|
||||
originalName: msg.originalName,
|
||||
fileCreatedAt: msg.fileCreatedAt
|
||||
}
|
||||
console.log(`Sync: Processing message ${msg.id}, has file:`, !!msg.fileId, `(${msg.originalName})`)
|
||||
messageMap.set(msg.id, transformedMsg)
|
||||
})
|
||||
|
||||
// Convert back to array, sorted by creation time
|
||||
const mergedMessages = Array.from(messageMap.values())
|
||||
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
|
||||
|
||||
console.log(`Merged result: ${mergedMessages.length} messages`)
|
||||
|
||||
// Update local storage
|
||||
appStore.setMessages(channelId, mergedMessages)
|
||||
await appStore.saveState()
|
||||
|
||||
} catch (error) {
|
||||
console.warn(`Failed to sync messages for channel ${channelId}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to send all unsent messages
|
||||
*/
|
||||
async retryUnsentMessages(): Promise<void> {
|
||||
const appStore = this.getAppStore()
|
||||
const unsentMessages = appStore.unsentMessages
|
||||
console.log(`Attempting to send ${unsentMessages.length} unsent messages`)
|
||||
|
||||
for (const unsentMsg of [...unsentMessages]) {
|
||||
try {
|
||||
console.log(`Sending unsent message: ${unsentMsg.content}`)
|
||||
|
||||
// Try to send the message
|
||||
const response = await apiService.createMessage(unsentMsg.channelId, unsentMsg.content)
|
||||
console.log(`Successfully sent unsent message, got ID: ${response.id}`)
|
||||
|
||||
// Create the sent message
|
||||
const sentMessage: ExtendedMessage = {
|
||||
id: response.id,
|
||||
channel_id: unsentMsg.channelId,
|
||||
content: unsentMsg.content,
|
||||
created_at: new Date().toISOString()
|
||||
}
|
||||
|
||||
// Add to messages and remove from unsent
|
||||
appStore.addMessage(sentMessage)
|
||||
appStore.removeUnsentMessage(unsentMsg.id)
|
||||
|
||||
// Save state immediately after successful send to ensure UI updates
|
||||
await appStore.saveState()
|
||||
|
||||
console.log(`Moved unsent message ${unsentMsg.id} to sent messages with ID ${response.id}`)
|
||||
console.log(`Unsent messages remaining: ${appStore.unsentMessages.length}`)
|
||||
|
||||
} catch (error) {
|
||||
console.warn(`Failed to send unsent message ${unsentMsg.id}:`, error)
|
||||
|
||||
// Increment retry count
|
||||
unsentMsg.retries = (unsentMsg.retries || 0) + 1
|
||||
|
||||
// Remove if too many retries (optional)
|
||||
if (unsentMsg.retries >= 5) {
|
||||
console.log(`Giving up on unsent message ${unsentMsg.id} after ${unsentMsg.retries} retries`)
|
||||
appStore.removeUnsentMessage(unsentMsg.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save state after processing
|
||||
await appStore.saveState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Full sync: channels and messages
|
||||
*/
|
||||
async fullSync(): Promise<void> {
|
||||
try {
|
||||
console.log('Starting full sync...')
|
||||
|
||||
const appStore = this.getAppStore()
|
||||
|
||||
// 1. Sync channels
|
||||
const channelsResponse = await apiService.getChannels()
|
||||
appStore.setChannels(channelsResponse.channels)
|
||||
|
||||
// 2. Retry unsent messages first
|
||||
await this.retryUnsentMessages()
|
||||
|
||||
// 3. Sync messages for current channel
|
||||
if (appStore.currentChannelId) {
|
||||
await this.syncChannelMessages(appStore.currentChannelId)
|
||||
}
|
||||
|
||||
// 4. Save everything
|
||||
await appStore.saveState()
|
||||
|
||||
console.log('Full sync completed')
|
||||
|
||||
} catch (error) {
|
||||
console.error('Full sync failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistic message sending with automatic sync
|
||||
*/
|
||||
async sendMessage(channelId: number, content: string): Promise<void> {
|
||||
try {
|
||||
console.log(`Optimistically sending message: ${content}`)
|
||||
|
||||
// Try to send immediately
|
||||
const response = await apiService.createMessage(channelId, content)
|
||||
|
||||
// Success - add to local messages
|
||||
const message: ExtendedMessage = {
|
||||
id: response.id,
|
||||
channel_id: channelId,
|
||||
content: content,
|
||||
created_at: new Date().toISOString()
|
||||
}
|
||||
|
||||
const appStore = this.getAppStore()
|
||||
appStore.addMessage(message)
|
||||
console.log(`Message sent successfully with ID: ${response.id}`)
|
||||
|
||||
} catch (error) {
|
||||
console.warn('Failed to send message immediately, queuing for later:', error)
|
||||
|
||||
// Failed - add to unsent messages
|
||||
const unsentMessage: UnsentMessage = {
|
||||
id: `unsent_${Date.now()}_${Math.random()}`,
|
||||
channelId: channelId,
|
||||
content: content,
|
||||
timestamp: Date.now(),
|
||||
retries: 0
|
||||
}
|
||||
|
||||
const appStore = this.getAppStore()
|
||||
appStore.addUnsentMessage(unsentMessage)
|
||||
await appStore.saveState()
|
||||
|
||||
throw error // Re-throw so caller knows it failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const syncService = new SyncService()
|
134
frontend-vue/src/services/websocket.ts
Normal file
134
frontend-vue/src/services/websocket.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import type { WebSocketEvent } from '@/types'
|
||||
|
||||
class WebSocketService {
|
||||
private ws: WebSocket | null = null
|
||||
private reconnectAttempts = 0
|
||||
private maxReconnectAttempts = 5
|
||||
private reconnectInterval = 1000
|
||||
private eventHandlers: Map<string, ((data: any) => void)[]> = new Map()
|
||||
|
||||
connect() {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
return
|
||||
}
|
||||
|
||||
// In development, connect to backend server (port 3000)
|
||||
// In production, use same host as frontend
|
||||
const isDev = import.meta.env.DEV
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const host = isDev ? 'localhost:3000' : window.location.host
|
||||
const wsUrl = `${protocol}//${host}`
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(wsUrl)
|
||||
this.setupEventListeners()
|
||||
} catch (error) {
|
||||
console.error('WebSocket connection failed:', error)
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private setupEventListeners() {
|
||||
if (!this.ws) return
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log('WebSocket connected')
|
||||
this.reconnectAttempts = 0
|
||||
this.emit('connected', null)
|
||||
}
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const data: WebSocketEvent = JSON.parse(event.data)
|
||||
console.log('WebSocket raw message received:', event.data)
|
||||
console.log('Parsed WebSocket data:', data)
|
||||
this.emit(data.type, data.data)
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error, 'Raw data:', event.data)
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
console.log('WebSocket disconnected:', event.code, event.reason)
|
||||
this.emit('disconnected', { code: event.code, reason: event.reason })
|
||||
|
||||
if (!event.wasClean && this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error)
|
||||
this.emit('error', error)
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error('Max reconnection attempts reached')
|
||||
return
|
||||
}
|
||||
|
||||
this.reconnectAttempts++
|
||||
const delay = this.reconnectInterval * Math.pow(2, this.reconnectAttempts - 1)
|
||||
|
||||
console.log(`Scheduling reconnect attempt ${this.reconnectAttempts} in ${delay}ms`)
|
||||
|
||||
setTimeout(() => {
|
||||
this.connect()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
on(event: string, handler: (data: any) => void) {
|
||||
if (!this.eventHandlers.has(event)) {
|
||||
this.eventHandlers.set(event, [])
|
||||
}
|
||||
this.eventHandlers.get(event)!.push(handler)
|
||||
}
|
||||
|
||||
off(event: string, handler: (data: any) => void) {
|
||||
const handlers = this.eventHandlers.get(event)
|
||||
if (handlers) {
|
||||
const index = handlers.indexOf(handler)
|
||||
if (index !== -1) {
|
||||
handlers.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private emit(event: string, data: any) {
|
||||
const handlers = this.eventHandlers.get(event)
|
||||
if (handlers) {
|
||||
handlers.forEach(handler => {
|
||||
try {
|
||||
handler(data)
|
||||
} catch (error) {
|
||||
console.error(`Error in WebSocket event handler for ${event}:`, error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
send(message: any) {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(message))
|
||||
} else {
|
||||
console.warn('WebSocket not connected, cannot send message')
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.ws) {
|
||||
this.ws.close(1000, 'Client disconnect')
|
||||
this.ws = null
|
||||
}
|
||||
this.eventHandlers.clear()
|
||||
this.reconnectAttempts = 0
|
||||
}
|
||||
|
||||
get isConnected(): boolean {
|
||||
return this.ws?.readyState === WebSocket.OPEN
|
||||
}
|
||||
}
|
||||
|
||||
export const websocketService = new WebSocketService()
|
Reference in New Issue
Block a user