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
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 53s
This commit is contained in:
1 parent
dccf1a91e3
commit
84e0b6a4bd
10 files changed
+1773
-110
No files matched your search
+39
-1
@@ -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
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user