feat: implement live-sync for tasks, workspace note sharing, and document locking system
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 53s

This commit is contained in:
sandrodach committed 2026-10-25 02:23:46 +02:00
1 parent dccf1a91e3
commit 84e0b6a4bd
10 files changed
+1773 -110

No files matched your search

+39 -1
View File
@@ -14,7 +14,14 @@ import {
HelpCircle,
} from 'lucide-react';
import { AuthStatus, DashboardStats, SharedList, Thought, User } from './lib/types';
import { api, clearToken, getToken, triggerHaptic } from './lib/api';
import {
api,
clearToken,
getToken,
triggerHaptic,
connectWebSocket,
disconnectWebSocket,
} from './lib/api';
import {
AVAILABLE_THEMES,
initThemeOnBoot,
@@ -193,6 +200,35 @@ export const App: React.FC = () => {
};
}, []);
// WebSocket Connection Management
useEffect(() => {
if (user && getToken()) {
connectWebSocket();
} else {
disconnectWebSocket();
}
}, [user]);
// Refresh stats on task/doc real-time events
useEffect(() => {
const handleWs = (e: any) => {
const msg = e.detail;
if (!msg) return;
if (
typeof msg.event === 'string' &&
(msg.event.startsWith('TASK_') || msg.event.startsWith('DOC_'))
) {
api.getMe().then((res) => {
setStats(res.stats);
}).catch(() => {});
}
};
window.addEventListener('mindsift:ws', handleWs);
return () => {
window.removeEventListener('mindsift:ws', handleWs);
};
}, []);
// Close profile dropdown on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
@@ -239,6 +275,7 @@ export const App: React.FC = () => {
const handleLogout = async () => {
triggerHaptic(20);
disconnectWebSocket();
clearToken();
setUser(null);
setShowProfileMenu(false);
@@ -551,6 +588,7 @@ export const App: React.FC = () => {
{activeTab === 'workspace' && (
<WorkspaceView
currentUser={user}
initialDocId={selectedDocId}
onStatsChange={refreshAll}
/>
@@ -96,6 +96,71 @@ export const TasksView: React.FC<TasksViewProps> = ({
loadData();
}, [refreshTrigger, selectedListId]);
// Live-Sync WebSocket listener for tasks
useEffect(() => {
const handleWs = (e: any) => {
const msg = e.detail;
if (!msg || !msg.event) return;
if (msg.event === 'TASK_CREATED') {
const created = msg.payload as Thought;
if (
created &&
(created.status === 'converted' ||
created.kanbanStatus ||
created.taskListId ||
created.isCompleted !== undefined)
) {
setTasks((prev) => {
if (prev.some((t) => t.id === created.id)) return prev;
if (selectedListId !== 'all' && created.taskListId !== selectedListId) return prev;
return [created, ...prev];
});
onRefreshStats();
}
} else if (msg.event === 'TASK_UPDATED') {
const updated = msg.payload as Thought;
if (updated && updated.id) {
setTasks((prev) => {
const exists = prev.some((t) => t.id === updated.id);
if (!exists) {
if (
(updated.status === 'converted' ||
updated.kanbanStatus ||
updated.taskListId ||
updated.isCompleted !== undefined) &&
(selectedListId === 'all' || updated.taskListId === selectedListId)
) {
return [updated, ...prev];
}
return prev;
}
if (selectedListId !== 'all' && updated.taskListId !== selectedListId) {
return prev.filter((t) => t.id !== updated.id);
}
return prev.map((t) => {
if (t.id !== updated.id) return t;
return {
...updated,
notes: activeNotesTaskId === updated.id ? t.notes : updated.notes,
};
});
});
onRefreshStats();
}
} else if (msg.event === 'TASK_DELETED') {
const deletedId = typeof msg.payload === 'string' ? msg.payload : msg.payload?.id;
if (deletedId) {
setTasks((prev) => prev.filter((t) => t.id !== deletedId));
onRefreshStats();
}
}
};
window.addEventListener('mindsift:ws', handleWs);
return () => window.removeEventListener('mindsift:ws', handleWs);
}, [selectedListId, activeNotesTaskId, onRefreshStats]);
const handleToggleDone = async (task: Thought) => {
triggerHaptic(15);
const nextState = !task.isCompleted;
File diff suppressed because it is too large. Load diff
+150 -2
View File
@@ -4,6 +4,8 @@ import {
DashboardStats,
Document,
DocumentBlock,
DocShare,
DocLockInfo,
ListItem,
Organization,
PriorityLevel,
@@ -16,6 +18,7 @@ import {
SubTask,
User,
UserInvite,
WSMessage,
} from './types';
const TOKEN_STORAGE_KEY = 'mindsift_jwt_token';
@@ -428,6 +431,69 @@ export const api = {
await request(`/api/v1/documents/${id}`, { method: 'DELETE' });
},
// --- DOCUMENT SHARING ---
async getDocShares(docId: string): Promise<DocShare[]> {
const res = await request<DocShare[]>(`/api/v1/documents/${docId}/shares`);
return ensureArray(res);
},
async createDocShare(
docId: string,
payload: { userId?: string; email?: string; permission: 'read' | 'write' }
): Promise<DocShare> {
return request<DocShare>(`/api/v1/documents/${docId}/shares`, {
method: 'POST',
body: JSON.stringify(payload),
});
},
async deleteDocShare(docId: string, userId: string): Promise<void> {
await request(`/api/v1/documents/${docId}/shares/${userId}`, { method: 'DELETE' });
},
async getCollaborators(): Promise<User[]> {
const res = await request<User[]>('/api/v1/collaborators');
return ensureArray(res);
},
// --- DOCUMENT LOCKING ---
async getDocLock(docId: string): Promise<{ locked: boolean; lock?: DocLockInfo }> {
return request<{ locked: boolean; lock?: DocLockInfo }>(`/api/v1/documents/${docId}/lock`);
},
async lockDocument(
docId: string
): Promise<{ locked: boolean; acquired: boolean; lock?: DocLockInfo; message?: string }> {
try {
return await request<{ locked: boolean; acquired: boolean; lock?: DocLockInfo; message?: string }>(
`/api/v1/documents/${docId}/lock`,
{ method: 'POST' }
);
} catch (e: any) {
return { locked: true, acquired: false, message: e.message };
}
},
async heartbeatDocLock(docId: string): Promise<{ extended: boolean }> {
try {
return await request<{ extended: boolean }>(`/api/v1/documents/${docId}/lock/heartbeat`, {
method: 'POST',
});
} catch {
return { extended: false };
}
},
async unlockDocument(docId: string): Promise<{ unlocked: boolean }> {
try {
return await request<{ unlocked: boolean }>(`/api/v1/documents/${docId}/lock`, {
method: 'DELETE',
});
} catch {
return { unlocked: false };
}
},
// --- SPACES & REALTIME LISTS ---
async getSpaces(): Promise<Space[]> {
const res = await request<Space[]>('/api/v1/spaces');
@@ -700,8 +766,90 @@ export function getOfflineQueue(): Thought[] {
}
}
export function buildWebSocketURL(spaceId: string): string {
let activeGlobalWS: WebSocket | null = null;
let wsReconnectTimer: any = null;
let wsListeners: ((msg: WSMessage) => void)[] = [];
let manualDisconnect = false;
export function buildWebSocketURL(spaceId?: string): string {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const token = getToken() || '';
return `${protocol}//${window.location.host}/api/v1/ws/spaces/${encodeURIComponent(spaceId)}?token=${encodeURIComponent(token)}`;
if (spaceId) {
return `${protocol}//${window.location.host}/api/v1/ws/spaces/${encodeURIComponent(spaceId)}?token=${encodeURIComponent(token)}`;
}
return `${protocol}//${window.location.host}/api/v1/ws?token=${encodeURIComponent(token)}`;
}
export function connectWebSocket(): void {
const token = getToken();
if (!token) return;
manualDisconnect = false;
if (activeGlobalWS && (activeGlobalWS.readyState === WebSocket.OPEN || activeGlobalWS.readyState === WebSocket.CONNECTING)) {
return;
}
try {
const url = buildWebSocketURL();
const ws = new WebSocket(url);
activeGlobalWS = ws;
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data) as WSMessage;
wsListeners.forEach((fn) => {
try {
fn(msg);
} catch (e) {
console.error('WS listener error', e);
}
});
window.dispatchEvent(new CustomEvent('mindsift:ws', { detail: msg }));
} catch (err) {
console.error('Failed to parse WS message', err);
}
};
ws.onclose = () => {
activeGlobalWS = null;
if (!manualDisconnect && getToken()) {
clearTimeout(wsReconnectTimer);
wsReconnectTimer = setTimeout(() => {
connectWebSocket();
}, 3000);
}
};
ws.onerror = () => {
try {
ws.close();
} catch {}
};
} catch (e) {
console.error('Failed to connect WebSocket', e);
}
}
export function disconnectWebSocket(): void {
manualDisconnect = true;
clearTimeout(wsReconnectTimer);
if (activeGlobalWS) {
try {
activeGlobalWS.close();
} catch {}
activeGlobalWS = null;
}
}
export function sendWebSocketMessage(msg: any): void {
if (activeGlobalWS && activeGlobalWS.readyState === WebSocket.OPEN) {
activeGlobalWS.send(typeof msg === 'string' ? msg : JSON.stringify(msg));
}
}
export function onWebSocketMessage(callback: (msg: WSMessage) => void): () => void {
wsListeners.push(callback);
return () => {
wsListeners = wsListeners.filter((fn) => fn !== callback);
};
}
+26 -2
View File
@@ -128,6 +128,24 @@ export interface DocumentBlock {
collapsed?: boolean;
}
export interface DocShare {
id: string;
docId: string;
userId: string;
userEmail?: string;
displayName?: string;
permission: 'read' | 'write';
createdBy: string;
createdAt: string;
}
export interface DocLockInfo {
docId: string;
userId: string;
userName: string;
expiresAt: string;
}
export interface Document {
id: string;
userId: string;
@@ -142,6 +160,12 @@ export interface Document {
isArchived: boolean;
createdAt: string;
updatedAt: string;
// Collaboration & Permissions
permission?: 'owner' | 'write' | 'read';
isOwner?: boolean;
ownerName?: string;
sharedWith?: DocShare[];
}
export interface SpaceMember {
@@ -212,8 +236,8 @@ export interface DashboardStats {
}
export interface WSMessage<T = any> {
event: 'ITEM_CREATED' | 'ITEM_UPDATED' | 'ITEM_TOGGLED' | 'ITEM_DELETED' | 'LIST_CLEARED' | 'LIST_CREATED' | 'LIST_DELETED' | 'USER_PRESENCE';
space_id: string;
event: string;
space_id?: string;
list_id?: string;
payload: T;
timestamp: string;