Initial commit
Dieser Commit ist enthalten in:
577
frontend/js/sync.js
Normale Datei
577
frontend/js/sync.js
Normale Datei
@ -0,0 +1,577 @@
|
||||
/**
|
||||
* TASKMATE - Real-time Sync Module
|
||||
* =================================
|
||||
* WebSocket synchronization with Socket.io
|
||||
*/
|
||||
|
||||
import store from './store.js';
|
||||
import api from './api.js';
|
||||
|
||||
class SyncManager {
|
||||
constructor() {
|
||||
this.socket = null;
|
||||
this.isConnected = false;
|
||||
this.reconnectAttempts = 0;
|
||||
this.maxReconnectAttempts = 10;
|
||||
this.reconnectDelay = 1000;
|
||||
this.pendingOperations = [];
|
||||
this.operationQueue = [];
|
||||
this.isProcessingQueue = false;
|
||||
}
|
||||
|
||||
// Initialize Socket.io connection
|
||||
async connect() {
|
||||
if (this.socket?.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = api.getToken();
|
||||
if (!token) {
|
||||
console.log('[Sync] No auth token, skipping connection');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Socket.io is loaded globally via script tag
|
||||
if (typeof io === 'undefined') {
|
||||
throw new Error('Socket.io not loaded');
|
||||
}
|
||||
|
||||
this.socket = io({
|
||||
auth: { token },
|
||||
reconnection: true,
|
||||
reconnectionAttempts: this.maxReconnectAttempts,
|
||||
reconnectionDelay: this.reconnectDelay,
|
||||
reconnectionDelayMax: 10000,
|
||||
timeout: 20000
|
||||
});
|
||||
|
||||
this.setupEventListeners();
|
||||
} catch (error) {
|
||||
console.error('[Sync] Failed to connect:', error);
|
||||
store.setSyncStatus('error');
|
||||
}
|
||||
}
|
||||
|
||||
// Setup socket event listeners
|
||||
setupEventListeners() {
|
||||
if (!this.socket) return;
|
||||
|
||||
// Connection events
|
||||
this.socket.on('connect', () => {
|
||||
console.log('[Sync] Connected');
|
||||
this.isConnected = true;
|
||||
this.reconnectAttempts = 0;
|
||||
store.setSyncStatus('synced');
|
||||
|
||||
// Join current project room
|
||||
const projectId = store.get('currentProjectId');
|
||||
if (projectId) {
|
||||
this.joinProject(projectId);
|
||||
}
|
||||
|
||||
// Process pending operations
|
||||
this.processPendingOperations();
|
||||
});
|
||||
|
||||
this.socket.on('disconnect', (reason) => {
|
||||
console.log('[Sync] Disconnected:', reason);
|
||||
this.isConnected = false;
|
||||
|
||||
if (reason === 'io server disconnect') {
|
||||
// Server initiated disconnect, try reconnecting
|
||||
this.socket.connect();
|
||||
}
|
||||
|
||||
store.setSyncStatus('offline');
|
||||
});
|
||||
|
||||
this.socket.on('connect_error', (error) => {
|
||||
console.error('[Sync] Connection error:', error);
|
||||
this.reconnectAttempts++;
|
||||
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
store.setSyncStatus('error');
|
||||
} else {
|
||||
store.setSyncStatus('offline');
|
||||
}
|
||||
});
|
||||
|
||||
// Auth error
|
||||
this.socket.on('error', (error) => {
|
||||
console.error('[Sync] Socket error:', error);
|
||||
|
||||
if (error.type === 'auth') {
|
||||
// Auth failed, logout
|
||||
window.dispatchEvent(new CustomEvent('auth:logout'));
|
||||
}
|
||||
});
|
||||
|
||||
// Data sync events
|
||||
this.socket.on('project:updated', (data) => {
|
||||
this.handleProjectUpdated(data);
|
||||
});
|
||||
|
||||
this.socket.on('column:created', (data) => {
|
||||
this.handleColumnCreated(data);
|
||||
});
|
||||
|
||||
this.socket.on('column:updated', (data) => {
|
||||
this.handleColumnUpdated(data);
|
||||
});
|
||||
|
||||
this.socket.on('column:deleted', (data) => {
|
||||
this.handleColumnDeleted(data);
|
||||
});
|
||||
|
||||
this.socket.on('column:reordered', (data) => {
|
||||
this.handleColumnsReordered(data);
|
||||
});
|
||||
|
||||
this.socket.on('task:created', (data) => {
|
||||
this.handleTaskCreated(data);
|
||||
});
|
||||
|
||||
this.socket.on('task:updated', (data) => {
|
||||
this.handleTaskUpdated(data);
|
||||
});
|
||||
|
||||
this.socket.on('task:deleted', (data) => {
|
||||
this.handleTaskDeleted(data);
|
||||
});
|
||||
|
||||
this.socket.on('task:moved', (data) => {
|
||||
this.handleTaskMoved(data);
|
||||
});
|
||||
|
||||
this.socket.on('label:created', (data) => {
|
||||
this.handleLabelCreated(data);
|
||||
});
|
||||
|
||||
this.socket.on('label:updated', (data) => {
|
||||
this.handleLabelUpdated(data);
|
||||
});
|
||||
|
||||
this.socket.on('label:deleted', (data) => {
|
||||
this.handleLabelDeleted(data);
|
||||
});
|
||||
|
||||
this.socket.on('comment:created', (data) => {
|
||||
this.handleCommentCreated(data);
|
||||
});
|
||||
|
||||
this.socket.on('subtask:updated', (data) => {
|
||||
this.handleSubtaskUpdated(data);
|
||||
});
|
||||
|
||||
// User presence events
|
||||
this.socket.on('user:joined', (data) => {
|
||||
this.handleUserJoined(data);
|
||||
});
|
||||
|
||||
this.socket.on('user:left', (data) => {
|
||||
this.handleUserLeft(data);
|
||||
});
|
||||
|
||||
this.socket.on('user:typing', (data) => {
|
||||
this.handleUserTyping(data);
|
||||
});
|
||||
|
||||
// Notification events
|
||||
this.socket.on('notification:new', (data) => {
|
||||
this.handleNotificationNew(data);
|
||||
});
|
||||
|
||||
this.socket.on('notification:count', (data) => {
|
||||
this.handleNotificationCount(data);
|
||||
});
|
||||
|
||||
this.socket.on('notification:deleted', (data) => {
|
||||
this.handleNotificationDeleted(data);
|
||||
});
|
||||
}
|
||||
|
||||
// Disconnect socket
|
||||
disconnect() {
|
||||
if (this.socket) {
|
||||
this.socket.disconnect();
|
||||
this.socket = null;
|
||||
this.isConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Join project room
|
||||
joinProject(projectId) {
|
||||
if (!this.socket?.connected) return;
|
||||
|
||||
this.socket.emit('project:join', { projectId });
|
||||
console.log('[Sync] Joined project:', projectId);
|
||||
}
|
||||
|
||||
// Leave project room
|
||||
leaveProject(projectId) {
|
||||
if (!this.socket?.connected) return;
|
||||
|
||||
this.socket.emit('project:leave', { projectId });
|
||||
console.log('[Sync] Left project:', projectId);
|
||||
}
|
||||
|
||||
// Emit event with queueing for offline support
|
||||
emit(event, data) {
|
||||
if (this.socket?.connected) {
|
||||
this.socket.emit(event, data);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Queue for later
|
||||
this.pendingOperations.push({ event, data, timestamp: Date.now() });
|
||||
store.setSyncStatus('offline');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Process pending operations when reconnected
|
||||
async processPendingOperations() {
|
||||
if (!this.socket?.connected || this.pendingOperations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Sync] Processing ${this.pendingOperations.length} pending operations`);
|
||||
store.setSyncStatus('syncing');
|
||||
|
||||
const operations = [...this.pendingOperations];
|
||||
this.pendingOperations = [];
|
||||
|
||||
for (const op of operations) {
|
||||
try {
|
||||
this.socket.emit(op.event, op.data);
|
||||
await this.delay(100); // Small delay between operations
|
||||
} catch (error) {
|
||||
console.error('[Sync] Failed to process operation:', error);
|
||||
// Re-queue failed operation
|
||||
this.pendingOperations.push(op);
|
||||
}
|
||||
}
|
||||
|
||||
store.setSyncStatus(this.pendingOperations.length > 0 ? 'offline' : 'synced');
|
||||
}
|
||||
|
||||
// Helper delay function
|
||||
delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// =====================
|
||||
// EVENT HANDLERS
|
||||
// =====================
|
||||
|
||||
handleProjectUpdated(data) {
|
||||
const { project, userId } = data;
|
||||
|
||||
// Ignore own updates
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
store.updateProject(project.id, project);
|
||||
|
||||
this.showNotification('Projekt aktualisiert', `${data.username} hat das Projekt aktualisiert`);
|
||||
}
|
||||
|
||||
handleColumnCreated(data) {
|
||||
const { column, userId } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
store.addColumn(column);
|
||||
|
||||
this.showNotification('Neue Spalte', `${data.username} hat eine Spalte erstellt`);
|
||||
}
|
||||
|
||||
handleColumnUpdated(data) {
|
||||
const { column, userId } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
store.updateColumn(column.id, column);
|
||||
}
|
||||
|
||||
handleColumnDeleted(data) {
|
||||
const { columnId, userId } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
store.removeColumn(columnId);
|
||||
}
|
||||
|
||||
handleColumnsReordered(data) {
|
||||
const { columnIds, userId } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
store.reorderColumns(columnIds);
|
||||
}
|
||||
|
||||
handleTaskCreated(data) {
|
||||
const { task, userId, username } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
store.addTask(task);
|
||||
|
||||
this.showNotification('Neue Aufgabe', `${username} hat "${task.title}" erstellt`);
|
||||
}
|
||||
|
||||
handleTaskUpdated(data) {
|
||||
const { task, userId, changes } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
store.updateTask(task.id, task);
|
||||
|
||||
// Show notification for significant changes
|
||||
if (changes.includes('status') || changes.includes('assignee')) {
|
||||
this.showNotification('Aufgabe aktualisiert', `"${task.title}" wurde aktualisiert`);
|
||||
}
|
||||
}
|
||||
|
||||
handleTaskDeleted(data) {
|
||||
const { taskId, userId, taskTitle } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
store.removeTask(taskId);
|
||||
|
||||
this.showNotification('Aufgabe gelöscht', `"${taskTitle}" wurde gelöscht`);
|
||||
}
|
||||
|
||||
handleTaskMoved(data) {
|
||||
const { taskId, columnId, position, userId } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
store.moveTask(taskId, columnId, position);
|
||||
}
|
||||
|
||||
handleLabelCreated(data) {
|
||||
const { label, userId } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
store.addLabel(label);
|
||||
}
|
||||
|
||||
handleLabelUpdated(data) {
|
||||
const { label, userId } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
store.updateLabel(label.id, label);
|
||||
}
|
||||
|
||||
handleLabelDeleted(data) {
|
||||
const { labelId, userId } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
store.removeLabel(labelId);
|
||||
}
|
||||
|
||||
handleCommentCreated(data) {
|
||||
const { taskId, comment, userId, username } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
// Update task comment count
|
||||
const task = store.getTaskById(taskId);
|
||||
if (task) {
|
||||
store.updateTask(taskId, {
|
||||
commentCount: (task.commentCount || 0) + 1
|
||||
});
|
||||
}
|
||||
|
||||
// Check for mention
|
||||
const currentUser = store.get('currentUser');
|
||||
if (comment.content.includes(`@${currentUser?.username}`)) {
|
||||
this.showNotification(
|
||||
'Du wurdest erwähnt',
|
||||
`${username} hat dich in "${task?.title}" erwähnt`,
|
||||
'mention'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
handleSubtaskUpdated(data) {
|
||||
const { taskId, subtask, userId } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
// Trigger task refresh if viewing this task
|
||||
const editingTask = store.get('editingTask');
|
||||
if (editingTask?.id === taskId) {
|
||||
window.dispatchEvent(new CustomEvent('task:refresh', {
|
||||
detail: { taskId }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
handleUserJoined(data) {
|
||||
const { userId, username } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
this.showNotification('Benutzer online', `${username} ist jetzt online`, 'info');
|
||||
}
|
||||
|
||||
handleUserLeft(data) {
|
||||
const { userId, username } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
// Optional: show notification
|
||||
}
|
||||
|
||||
handleUserTyping(data) {
|
||||
const { userId, taskId, isTyping } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
window.dispatchEvent(new CustomEvent('user:typing', {
|
||||
detail: { userId, taskId, isTyping }
|
||||
}));
|
||||
}
|
||||
|
||||
handleNotificationNew(data) {
|
||||
const { notification } = data;
|
||||
window.dispatchEvent(new CustomEvent('notification:new', {
|
||||
detail: { notification }
|
||||
}));
|
||||
}
|
||||
|
||||
handleNotificationCount(data) {
|
||||
const { count } = data;
|
||||
window.dispatchEvent(new CustomEvent('notification:count', {
|
||||
detail: { count }
|
||||
}));
|
||||
}
|
||||
|
||||
handleNotificationDeleted(data) {
|
||||
const { notificationId } = data;
|
||||
window.dispatchEvent(new CustomEvent('notification:deleted', {
|
||||
detail: { notificationId }
|
||||
}));
|
||||
}
|
||||
|
||||
// =====================
|
||||
// OUTGOING EVENTS
|
||||
// =====================
|
||||
|
||||
notifyTaskCreated(task) {
|
||||
this.emit('task:create', {
|
||||
task,
|
||||
projectId: store.get('currentProjectId')
|
||||
});
|
||||
}
|
||||
|
||||
notifyTaskUpdated(task, changes) {
|
||||
this.emit('task:update', {
|
||||
task,
|
||||
changes,
|
||||
projectId: store.get('currentProjectId')
|
||||
});
|
||||
}
|
||||
|
||||
notifyTaskDeleted(taskId, taskTitle) {
|
||||
this.emit('task:delete', {
|
||||
taskId,
|
||||
taskTitle,
|
||||
projectId: store.get('currentProjectId')
|
||||
});
|
||||
}
|
||||
|
||||
notifyTaskMoved(taskId, columnId, position) {
|
||||
this.emit('task:move', {
|
||||
taskId,
|
||||
columnId,
|
||||
position,
|
||||
projectId: store.get('currentProjectId')
|
||||
});
|
||||
}
|
||||
|
||||
notifyColumnCreated(column) {
|
||||
this.emit('column:create', {
|
||||
column,
|
||||
projectId: store.get('currentProjectId')
|
||||
});
|
||||
}
|
||||
|
||||
notifyColumnUpdated(column) {
|
||||
this.emit('column:update', {
|
||||
column,
|
||||
projectId: store.get('currentProjectId')
|
||||
});
|
||||
}
|
||||
|
||||
notifyColumnDeleted(columnId) {
|
||||
this.emit('column:delete', {
|
||||
columnId,
|
||||
projectId: store.get('currentProjectId')
|
||||
});
|
||||
}
|
||||
|
||||
notifyColumnsReordered(columnIds) {
|
||||
this.emit('column:reorder', {
|
||||
columnIds,
|
||||
projectId: store.get('currentProjectId')
|
||||
});
|
||||
}
|
||||
|
||||
notifyTyping(taskId, isTyping) {
|
||||
this.emit('user:typing', {
|
||||
taskId,
|
||||
isTyping,
|
||||
projectId: store.get('currentProjectId')
|
||||
});
|
||||
}
|
||||
|
||||
// =====================
|
||||
// NOTIFICATIONS
|
||||
// =====================
|
||||
|
||||
showNotification(title, message, type = 'info') {
|
||||
window.dispatchEvent(new CustomEvent('toast:show', {
|
||||
detail: { title, message, type }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
const syncManager = new SyncManager();
|
||||
|
||||
// Listen for auth events
|
||||
window.addEventListener('auth:login', () => {
|
||||
syncManager.connect();
|
||||
});
|
||||
|
||||
window.addEventListener('auth:logout', () => {
|
||||
syncManager.disconnect();
|
||||
});
|
||||
|
||||
// Listen for project changes
|
||||
store.subscribe('currentProjectId', (newProjectId, oldProjectId) => {
|
||||
if (oldProjectId) {
|
||||
syncManager.leaveProject(oldProjectId);
|
||||
}
|
||||
if (newProjectId) {
|
||||
syncManager.joinProject(newProjectId);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for online/offline events
|
||||
window.addEventListener('online', () => {
|
||||
store.setOnline(true);
|
||||
syncManager.connect();
|
||||
});
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
store.setOnline(false);
|
||||
});
|
||||
|
||||
export default syncManager;
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren