MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m12s
1439 lines
41 KiB
TypeScript
1439 lines
41 KiB
TypeScript
import {
|
|
APIKey,
|
|
AuthStatus,
|
|
DashboardStats,
|
|
Document,
|
|
DocumentBlock,
|
|
DocShare,
|
|
DocLockInfo,
|
|
KanbanColumn,
|
|
ListItem,
|
|
ListShare,
|
|
Organization,
|
|
PriorityLevel,
|
|
SharedList,
|
|
SharedFolder,
|
|
FolderShare,
|
|
Space,
|
|
SpaceMember,
|
|
SpaceShare,
|
|
SystemSettings,
|
|
Team,
|
|
TaskList,
|
|
TaskListShare,
|
|
Thought,
|
|
SubTask,
|
|
TagDetails,
|
|
TagSummary,
|
|
User,
|
|
UserInvite,
|
|
WSMessage,
|
|
} from './types';
|
|
|
|
const TOKEN_STORAGE_KEY = 'mindsift_jwt_token';
|
|
const OFFLINE_QUEUE_KEY = 'mindsift_offline_capture_queue';
|
|
|
|
export function getToken(): string | null {
|
|
return localStorage.getItem(TOKEN_STORAGE_KEY);
|
|
}
|
|
|
|
export function setToken(token: string): void {
|
|
localStorage.setItem(TOKEN_STORAGE_KEY, token);
|
|
}
|
|
|
|
export function clearToken(): void {
|
|
localStorage.removeItem(TOKEN_STORAGE_KEY);
|
|
}
|
|
|
|
export function triggerHaptic(pattern: number | number[] = 15): void {
|
|
try {
|
|
if (typeof navigator !== 'undefined' && 'vibrate' in navigator) {
|
|
navigator.vibrate(pattern);
|
|
}
|
|
} catch {
|
|
// Ignore on desktop browsers
|
|
}
|
|
}
|
|
|
|
function ensureArray<T>(val: T[] | null | undefined): T[] {
|
|
return Array.isArray(val) ? val : [];
|
|
}
|
|
|
|
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
|
const token = getToken();
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-cache',
|
|
...(options.headers as Record<string, string>),
|
|
};
|
|
|
|
if (token) {
|
|
headers['Authorization'] = `Bearer ${token}`;
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
const timeoutId = window.setTimeout(() => controller.abort(), 12000);
|
|
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(path, {
|
|
...options,
|
|
headers,
|
|
cache: 'no-store',
|
|
signal: controller.signal,
|
|
});
|
|
} finally {
|
|
window.clearTimeout(timeoutId);
|
|
}
|
|
|
|
if (
|
|
res.status === 401 &&
|
|
!path.includes('/auth/login') &&
|
|
!path.includes('/auth/register') &&
|
|
!path.includes('/auth/setup') &&
|
|
!path.includes('/auth/status')
|
|
) {
|
|
clearToken();
|
|
window.dispatchEvent(new CustomEvent('mindsift:unauthorized'));
|
|
}
|
|
|
|
if (!res.ok) {
|
|
let errMsg = `HTTP ${res.status}`;
|
|
try {
|
|
const errData = await res.json();
|
|
if (errData.error) errMsg = errData.error;
|
|
} catch {
|
|
// ignore
|
|
}
|
|
throw new Error(errMsg);
|
|
}
|
|
|
|
return res.json();
|
|
}
|
|
|
|
export const api = {
|
|
// --- AUTH, FIRST-BOOT SETUP & SETTINGS ---
|
|
async getAuthStatus(): Promise<AuthStatus> {
|
|
return request<AuthStatus>('/api/v1/auth/status');
|
|
},
|
|
|
|
async completeSetup(payload: {
|
|
email: string;
|
|
password: string;
|
|
displayName: string;
|
|
instanceName?: string;
|
|
sharingMode?: 'open' | 'teams_only' | 'strict_private';
|
|
allowRegistration?: boolean;
|
|
createSampleData?: boolean;
|
|
}): Promise<{ token: string; user: User }> {
|
|
const res = await request<{ token: string; user: User }>('/api/v1/auth/setup', {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
setToken(res.token);
|
|
return res;
|
|
},
|
|
|
|
async login(loginOrEmail: string, password: string): Promise<{ token: string; user: User }> {
|
|
const res = await request<{ token: string; user: User }>('/api/v1/auth/login', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ email: loginOrEmail, username: loginOrEmail, login: loginOrEmail, password }),
|
|
});
|
|
setToken(res.token);
|
|
return res;
|
|
},
|
|
|
|
async register(
|
|
email: string,
|
|
password: string,
|
|
displayName: string,
|
|
inviteCode?: string
|
|
): Promise<{ token: string; user: User }> {
|
|
const res = await request<{ token: string; user: User }>('/api/v1/auth/register', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ email, password, displayName, inviteCode: inviteCode || '' }),
|
|
});
|
|
setToken(res.token);
|
|
return res;
|
|
},
|
|
|
|
async getMe(): Promise<{ user: User; stats: DashboardStats }> {
|
|
return request<{ user: User; stats: DashboardStats }>('/api/v1/auth/me');
|
|
},
|
|
|
|
async updateSettings(patch: {
|
|
displayName?: string;
|
|
email?: string;
|
|
password?: string;
|
|
avatarUrl?: string;
|
|
alarmChannels?: string[];
|
|
alarmEmail?: string;
|
|
discordWebhookUrl?: string;
|
|
customWebhookUrl?: string;
|
|
notifyBeforeMinutes?: number;
|
|
}): Promise<User> {
|
|
return request<User>('/api/v1/auth/settings', {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(patch),
|
|
});
|
|
},
|
|
|
|
async testAlarmChannel(
|
|
channel: 'email' | 'discord' | 'webhook',
|
|
target?: string
|
|
): Promise<{ status: string }> {
|
|
return request<{ status: string }>('/api/v1/auth/alarms/test', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ channel, target: target || '' }),
|
|
});
|
|
},
|
|
|
|
async getStats(): Promise<DashboardStats> {
|
|
return request<DashboardStats>('/api/v1/stats');
|
|
},
|
|
|
|
async getUsersDirectory(): Promise<User[]> {
|
|
const res = await request<User[]>('/api/v1/users/directory');
|
|
return ensureArray(res);
|
|
},
|
|
|
|
// --- INSTANT CAPTURE & OFFLINE SYNC ---
|
|
async captureThought(
|
|
content: string,
|
|
priority: PriorityLevel = 'normal',
|
|
source: 'manual' | 'voice' = 'manual',
|
|
dueDate = '',
|
|
options?: {
|
|
target?: 'inbox' | 'task' | 'list_item' | 'document';
|
|
targetListId?: string;
|
|
taskListId?: string;
|
|
}
|
|
): Promise<Thought> {
|
|
try {
|
|
const thought = await request<Thought>('/api/v1/inbox', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
content,
|
|
priority,
|
|
source,
|
|
dueDate,
|
|
target: options?.target || 'inbox',
|
|
targetListId: options?.targetListId || '',
|
|
taskListId: options?.taskListId || '',
|
|
}),
|
|
});
|
|
return thought;
|
|
} catch {
|
|
// Queue locally if offline
|
|
const queue = getOfflineQueue();
|
|
const offlineThought: Thought = {
|
|
id: `th_offline_${Date.now()}`,
|
|
userId: 'offline',
|
|
content,
|
|
tags: (content.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g) || []).map((t) => t.slice(1).toLowerCase()),
|
|
priority,
|
|
source,
|
|
status: 'inbox',
|
|
isCompleted: false,
|
|
dueDate,
|
|
taskListId: options?.taskListId || '',
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
queue.push(offlineThought);
|
|
localStorage.setItem(OFFLINE_QUEUE_KEY, JSON.stringify(queue));
|
|
return offlineThought;
|
|
}
|
|
},
|
|
|
|
async flushOfflineQueue(): Promise<number> {
|
|
const queue = getOfflineQueue();
|
|
if (queue.length === 0) return 0;
|
|
|
|
let synced = 0;
|
|
const remaining: Thought[] = [];
|
|
|
|
for (const item of queue) {
|
|
try {
|
|
await request<Thought>('/api/v1/inbox', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
content: item.content,
|
|
priority: item.priority,
|
|
source: item.source,
|
|
dueDate: item.dueDate || '',
|
|
taskListId: item.taskListId || '',
|
|
}),
|
|
});
|
|
synced++;
|
|
} catch {
|
|
remaining.push(item);
|
|
}
|
|
}
|
|
|
|
localStorage.setItem(OFFLINE_QUEUE_KEY, JSON.stringify(remaining));
|
|
return synced;
|
|
},
|
|
|
|
// --- THOUGHTS, TRIAGE & TASKS ---
|
|
async getTriageDeck(): Promise<Thought[]> {
|
|
const res = await request<Thought[]>('/api/v1/triage/deck');
|
|
return ensureArray(res);
|
|
},
|
|
|
|
async getThoughts(status = 'all'): Promise<Thought[]> {
|
|
const res = await request<Thought[]>(`/api/v1/thoughts?status=${encodeURIComponent(status)}`);
|
|
return ensureArray(res);
|
|
},
|
|
|
|
async triageAction(
|
|
thoughtId: string,
|
|
action: 'keep' | 'discard' | 'restore',
|
|
options?: {
|
|
convertTo?: 'task' | 'document' | 'list_item';
|
|
targetListId?: string;
|
|
taskListId?: string;
|
|
dueDate?: string;
|
|
}
|
|
): Promise<Thought> {
|
|
return request<Thought>(`/api/v1/triage/${thoughtId}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({
|
|
action,
|
|
convertTo: options?.convertTo || '',
|
|
targetListId: options?.targetListId || '',
|
|
taskListId: options?.taskListId || '',
|
|
dueDate: options?.dueDate || '',
|
|
}),
|
|
});
|
|
},
|
|
|
|
async getTasks(taskListId?: string): Promise<Thought[]> {
|
|
const query = taskListId ? `?taskListId=${encodeURIComponent(taskListId)}` : '';
|
|
const res = await request<Thought[]>(`/api/v1/tasks${query}`);
|
|
return ensureArray(res);
|
|
},
|
|
|
|
async reorderTasks(items: { id: string; sortOrder: number; kanbanStatus?: string }[], taskListId?: string): Promise<{ success: boolean }> {
|
|
const url = taskListId ? `/api/v1/task-lists/${taskListId}/tasks/reorder` : '/api/v1/tasks/reorder';
|
|
return request<{ success: boolean }>(url, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ items }),
|
|
});
|
|
},
|
|
|
|
async updateThought(
|
|
thoughtId: string,
|
|
patch: {
|
|
content?: string;
|
|
isCompleted?: boolean;
|
|
dueDate?: string;
|
|
priority?: string;
|
|
taskListId?: string;
|
|
kanbanStatus?: string;
|
|
notes?: string;
|
|
subtasks?: SubTask[];
|
|
tags?: string[];
|
|
}
|
|
): Promise<Thought> {
|
|
return request<Thought>(`/api/v1/thoughts/${thoughtId}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(patch),
|
|
});
|
|
},
|
|
|
|
async deleteThought(thoughtId: string): Promise<void> {
|
|
await request(`/api/v1/thoughts/${thoughtId}`, { method: 'DELETE' });
|
|
},
|
|
|
|
async purgeDiscarded(): Promise<{ purged: number }> {
|
|
return request<{ purged: number }>('/api/v1/thoughts/discarded/purge', { method: 'DELETE' });
|
|
},
|
|
|
|
// --- TASK LISTS ---
|
|
async getTaskLists(): Promise<TaskList[]> {
|
|
const res = await request<TaskList[]>('/api/v1/task-lists');
|
|
return ensureArray(res).map((tl) => ({
|
|
...tl,
|
|
members: ensureArray(tl.members),
|
|
shares: ensureArray(tl.shares),
|
|
columns: ensureArray(tl.columns),
|
|
}));
|
|
},
|
|
|
|
async createTaskList(payload: {
|
|
title: string;
|
|
icon?: string;
|
|
color?: string;
|
|
teamId?: string;
|
|
spaceId?: string;
|
|
columns?: KanbanColumn[];
|
|
}): Promise<TaskList> {
|
|
return request<TaskList>('/api/v1/task-lists', {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
},
|
|
|
|
async updateTaskList(
|
|
id: string,
|
|
patch: {
|
|
title?: string;
|
|
icon?: string;
|
|
color?: string;
|
|
teamId?: string;
|
|
spaceId?: string;
|
|
columns?: KanbanColumn[];
|
|
}
|
|
): Promise<TaskList> {
|
|
return request<TaskList>(`/api/v1/task-lists/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(patch),
|
|
});
|
|
},
|
|
|
|
async deleteTaskList(id: string): Promise<void> {
|
|
await request(`/api/v1/task-lists/${id}`, { method: 'DELETE' });
|
|
},
|
|
|
|
async getTaskListShares(id: string): Promise<TaskListShare[]> {
|
|
const res = await request<TaskListShare[]>(`/api/v1/task-lists/${id}/shares`);
|
|
return ensureArray(res);
|
|
},
|
|
|
|
async addTaskListShare(
|
|
id: string,
|
|
payload: { targetType: 'team' | 'user'; targetId: string }
|
|
): Promise<TaskListShare> {
|
|
return request<TaskListShare>(`/api/v1/task-lists/${id}/shares`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
},
|
|
|
|
async removeTaskListShare(id: string, shareId: string): Promise<void> {
|
|
await request(`/api/v1/task-lists/${id}/shares/${shareId}`, { method: 'DELETE' });
|
|
},
|
|
|
|
// --- WORKSPACE FOLDERS ---
|
|
async getFolders(): Promise<string[]> {
|
|
const res = await request<string[]>('/api/v1/folders');
|
|
return ensureArray(res);
|
|
},
|
|
|
|
async createFolder(path: string): Promise<{ path: string }> {
|
|
return request<{ path: string }>('/api/v1/folders', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ path }),
|
|
});
|
|
},
|
|
|
|
async deleteFolder(path: string): Promise<{ deleted: boolean; deletedDocIds: string[] }> {
|
|
return request<{ deleted: boolean; deletedDocIds: string[] }>(
|
|
`/api/v1/folders?path=${encodeURIComponent(path)}`,
|
|
{ method: 'DELETE' }
|
|
);
|
|
},
|
|
|
|
async moveFolder(sourcePath: string, targetParentPath: string): Promise<{ newPath: string; movedDocIds: string[] }> {
|
|
return request<{ newPath: string; movedDocIds: string[] }>('/api/v1/folders/move', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ sourcePath, targetParentPath }),
|
|
});
|
|
},
|
|
|
|
// --- WORKSPACE SHARED FOLDERS ---
|
|
async getSharedFolders(): Promise<SharedFolder[]> {
|
|
const res = await request<SharedFolder[]>('/api/v1/workspace/folders/shared');
|
|
return ensureArray(res);
|
|
},
|
|
|
|
async createSharedFolder(payload: { name: string; mountPath?: string }): Promise<SharedFolder> {
|
|
return request<SharedFolder>('/api/v1/workspace/folders/shared', {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
},
|
|
|
|
async getSharedFolder(id: string): Promise<SharedFolder> {
|
|
return request<SharedFolder>(`/api/v1/workspace/folders/shared/${id}`);
|
|
},
|
|
|
|
async deleteSharedFolder(id: string): Promise<void> {
|
|
await request(`/api/v1/workspace/folders/shared/${id}`, { method: 'DELETE' });
|
|
},
|
|
|
|
async updateFolderMount(id: string, mountPath: string): Promise<void> {
|
|
await request(`/api/v1/workspace/folders/shared/${id}/mount`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ mountPath }),
|
|
});
|
|
},
|
|
|
|
async getFolderShares(folderId: string): Promise<FolderShare[]> {
|
|
const res = await request<FolderShare[]>(`/api/v1/workspace/folders/shared/${folderId}/shares`);
|
|
return ensureArray(res);
|
|
},
|
|
|
|
async createFolderShare(
|
|
folderId: string,
|
|
payload: { targetType?: 'team' | 'user'; targetId?: string; userId?: string; email?: string; teamId?: string; permission: 'read' | 'write' }
|
|
): Promise<FolderShare> {
|
|
return request<FolderShare>(`/api/v1/workspace/folders/shared/${folderId}/shares`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
},
|
|
|
|
async deleteFolderShare(folderId: string, shareId: string): Promise<void> {
|
|
await request(`/api/v1/workspace/folders/shared/${folderId}/shares/${shareId}`, { method: 'DELETE' });
|
|
},
|
|
|
|
// --- WORKSPACE TAGS ---
|
|
async getWorkspaceTags(): Promise<TagSummary[]> {
|
|
const res = await request<TagSummary[]>('/api/v1/workspace/tags');
|
|
return ensureArray(res);
|
|
},
|
|
|
|
async getTagDetails(tag: string): Promise<TagDetails> {
|
|
const clean = encodeURIComponent(tag.replace(/^#/, ''));
|
|
const res = await request<TagDetails>(`/api/v1/workspace/tags/${clean}`);
|
|
return {
|
|
...res,
|
|
documents: ensureArray(res.documents).map((doc) => ({
|
|
...doc,
|
|
matchedBlocks: ensureArray(doc.matchedBlocks).map((mb) => ({
|
|
rootBlock: mb.rootBlock,
|
|
children: ensureArray(mb.children),
|
|
})),
|
|
})),
|
|
};
|
|
},
|
|
|
|
async updateTagPageNote(
|
|
tag: string,
|
|
content: string
|
|
): Promise<{ tag: string; content: string; updatedAt: string }> {
|
|
const clean = encodeURIComponent(tag.replace(/^#/, ''));
|
|
return request<{ tag: string; content: string; updatedAt: string }>(
|
|
`/api/v1/workspace/tags/${clean}/note`,
|
|
{
|
|
method: 'PUT',
|
|
body: JSON.stringify({ content }),
|
|
}
|
|
);
|
|
},
|
|
|
|
// --- WORKSPACE DOCUMENTS ---
|
|
async getDocuments(): Promise<Document[]> {
|
|
const res = await request<Document[]>('/api/v1/documents');
|
|
return ensureArray(res).map((d) => ({
|
|
...d,
|
|
blocks: ensureArray(d.blocks),
|
|
tags: ensureArray(d.tags),
|
|
}));
|
|
},
|
|
|
|
async getDocument(id: string): Promise<Document> {
|
|
const d = await request<Document>(`/api/v1/documents/${id}`);
|
|
return {
|
|
...d,
|
|
blocks: ensureArray(d.blocks),
|
|
tags: ensureArray(d.tags),
|
|
};
|
|
},
|
|
|
|
async createDocument(payload: {
|
|
title: string;
|
|
icon?: string;
|
|
folderPath?: string;
|
|
blocks?: DocumentBlock[];
|
|
tags?: string[];
|
|
isPinned?: boolean;
|
|
}): Promise<Document> {
|
|
const d = await request<Document>('/api/v1/documents', {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
return {
|
|
...d,
|
|
blocks: ensureArray(d.blocks),
|
|
tags: ensureArray(d.tags),
|
|
};
|
|
},
|
|
|
|
async updateDocument(
|
|
id: string,
|
|
patch: {
|
|
title?: string;
|
|
icon?: string;
|
|
folderPath?: string;
|
|
blocks?: DocumentBlock[];
|
|
tags?: string[];
|
|
isPinned?: boolean;
|
|
}
|
|
): Promise<Document> {
|
|
const d = await request<Document>(`/api/v1/documents/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(patch),
|
|
});
|
|
return {
|
|
...d,
|
|
blocks: ensureArray(d.blocks),
|
|
tags: ensureArray(d.tags),
|
|
};
|
|
},
|
|
|
|
async patchDocumentBlocks(
|
|
id: string,
|
|
payload: {
|
|
upserted?: DocumentBlock[];
|
|
deletedIds?: string[];
|
|
blockOrder?: string[];
|
|
}
|
|
): Promise<Document> {
|
|
const d = await request<Document>(`/api/v1/documents/${id}/blocks`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
return {
|
|
...d,
|
|
blocks: ensureArray(d.blocks),
|
|
tags: ensureArray(d.tags),
|
|
};
|
|
},
|
|
|
|
async joinSSERoom(sseId: string, room: string, action: 'join' | 'leave' = 'join'): Promise<{ success: boolean }> {
|
|
return request<{ success: boolean }>('/api/v1/events/rooms', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ sseId, room, action }),
|
|
});
|
|
},
|
|
|
|
async deleteDocument(id: string): Promise<void> {
|
|
await request(`/api/v1/documents/${id}`, { method: 'DELETE' });
|
|
},
|
|
|
|
async moveDocument(id: string, folderPath: string): Promise<Document> {
|
|
const d = await request<Document>(`/api/v1/documents/${id}/move`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ folderPath }),
|
|
});
|
|
return {
|
|
...d,
|
|
blocks: ensureArray(d.blocks),
|
|
tags: ensureArray(d.tags),
|
|
};
|
|
},
|
|
|
|
// --- 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; teamId?: 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);
|
|
},
|
|
|
|
// --- SPACES & REALTIME LISTS ---
|
|
async getSpaces(): Promise<Space[]> {
|
|
const res = await request<Space[]>('/api/v1/spaces');
|
|
return ensureArray(res).map((s) => ({
|
|
...s,
|
|
members: ensureArray(s.members),
|
|
shares: ensureArray(s.shares),
|
|
}));
|
|
},
|
|
|
|
async getSpaceShares(spaceId: string): Promise<SpaceShare[]> {
|
|
const res = await request<SpaceShare[]>(`/api/v1/spaces/${spaceId}/shares`);
|
|
return ensureArray(res);
|
|
},
|
|
|
|
async createSpace(name: string, icon: string, description: string): Promise<Space> {
|
|
return request<Space>('/api/v1/spaces', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ name, icon, description }),
|
|
});
|
|
},
|
|
|
|
async joinSpace(inviteCode: string): Promise<Space> {
|
|
return request<Space>('/api/v1/spaces/join', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ inviteCode }),
|
|
});
|
|
},
|
|
|
|
async addSpaceMember(spaceId: string, target: { userId?: string; email?: string; teamId?: string; role?: string }): Promise<any> {
|
|
return request<any>(`/api/v1/spaces/${spaceId}/members`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(target),
|
|
});
|
|
},
|
|
|
|
async removeSpaceMember(spaceId: string, userId: string): Promise<void> {
|
|
await request(`/api/v1/spaces/${spaceId}/members/${userId}`, {
|
|
method: 'DELETE',
|
|
});
|
|
},
|
|
|
|
async getSpaceLists(spaceId: string): Promise<SharedList[]> {
|
|
const res = await request<SharedList[]>(`/api/v1/spaces/${spaceId}/lists`);
|
|
return ensureArray(res).map((l) => ({
|
|
...l,
|
|
items: ensureArray(l.items),
|
|
}));
|
|
},
|
|
|
|
async getAllLists(): Promise<SharedList[]> {
|
|
const res = await request<SharedList[]>('/api/v1/lists');
|
|
return ensureArray(res).map((l) => ({
|
|
...l,
|
|
items: ensureArray(l.items),
|
|
shares: ensureArray(l.shares),
|
|
}));
|
|
},
|
|
|
|
async getListById(listId: string): Promise<SharedList> {
|
|
const l = await request<SharedList>(`/api/v1/lists/${listId}`);
|
|
return {
|
|
...l,
|
|
items: ensureArray(l.items),
|
|
shares: ensureArray(l.shares),
|
|
};
|
|
},
|
|
|
|
async createList(titleOrSpaceId: string, titleOrIcon?: string, iconOrType?: string, listType?: 'shopping' | 'checklist' | 'vacation'): Promise<SharedList> {
|
|
let title = titleOrSpaceId;
|
|
let icon = titleOrIcon || '🛒';
|
|
let type = (iconOrType as any) || 'shopping';
|
|
if (listType !== undefined) {
|
|
title = titleOrIcon || '';
|
|
icon = iconOrType || '🛒';
|
|
type = listType;
|
|
}
|
|
const l = await request<SharedList>('/api/v1/lists', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ title, icon, listType: type }),
|
|
});
|
|
return {
|
|
...l,
|
|
items: ensureArray(l.items),
|
|
shares: ensureArray(l.shares),
|
|
};
|
|
},
|
|
|
|
async deleteList(listId: string): Promise<void> {
|
|
await request(`/api/v1/lists/${listId}`, { method: 'DELETE' });
|
|
},
|
|
|
|
async getListShares(listId: string): Promise<ListShare[]> {
|
|
const res = await request<ListShare[]>(`/api/v1/lists/${listId}/shares`);
|
|
return ensureArray(res);
|
|
},
|
|
|
|
async addListShare(listId: string, target: { targetType: 'team' | 'user'; targetId: string; role?: string }): Promise<ListShare> {
|
|
return request<ListShare>(`/api/v1/lists/${listId}/shares`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(target),
|
|
});
|
|
},
|
|
|
|
async removeListShare(listId: string, shareId: string): Promise<void> {
|
|
await request(`/api/v1/lists/${listId}/shares/${shareId}`, { method: 'DELETE' });
|
|
},
|
|
|
|
async createListItem(listId: string, content: string, category = '', quantity = ''): Promise<ListItem> {
|
|
return request<ListItem>(`/api/v1/lists/${listId}/items`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ content, category, quantity }),
|
|
});
|
|
},
|
|
|
|
async toggleListItem(listId: string, itemId: string, isCompleted?: boolean): Promise<ListItem> {
|
|
return request<ListItem>(`/api/v1/lists/${listId}/items/${itemId}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ isCompleted }),
|
|
});
|
|
},
|
|
|
|
async deleteListItem(listId: string, itemId: string): Promise<void> {
|
|
await request(`/api/v1/lists/${listId}/items/${itemId}`, { method: 'DELETE' });
|
|
},
|
|
|
|
async clearCompletedItems(listId: string): Promise<{ removed: number }> {
|
|
return request<{ removed: number }>(`/api/v1/lists/${listId}/clear-completed`, {
|
|
method: 'POST',
|
|
});
|
|
},
|
|
|
|
async reorderListItems(
|
|
listId: string,
|
|
items: { id: string; sortOrder: number; category?: string; content?: string }[]
|
|
): Promise<{ success: boolean }> {
|
|
return request<{ success: boolean }>(`/api/v1/lists/${listId}/items/reorder`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ items }),
|
|
});
|
|
},
|
|
|
|
// --- ORGANIZATIONS & TEAMS ---
|
|
async getOrganizations(): Promise<Organization[]> {
|
|
const res = await request<Organization[]>('/api/v1/organizations');
|
|
return ensureArray(res).map((o) => ({
|
|
...o,
|
|
teams: ensureArray(o.teams).map((t) => ({
|
|
...t,
|
|
members: ensureArray(t.members),
|
|
})),
|
|
}));
|
|
},
|
|
|
|
async createOrganization(name: string): Promise<Organization> {
|
|
return request<Organization>('/api/v1/organizations', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ name }),
|
|
});
|
|
},
|
|
|
|
async deleteOrganization(id: string): Promise<void> {
|
|
await request(`/api/v1/organizations/${id}`, { method: 'DELETE' });
|
|
},
|
|
|
|
async getOrgTeams(orgId: string): Promise<Team[]> {
|
|
const res = await request<Team[]>(`/api/v1/organizations/${orgId}/teams`);
|
|
return ensureArray(res).map((t) => ({ ...t, members: ensureArray(t.members) }));
|
|
},
|
|
|
|
async createOrgTeam(orgId: string, name: string, description: string): Promise<Team> {
|
|
return request<Team>(`/api/v1/organizations/${orgId}/teams`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ name, description }),
|
|
});
|
|
},
|
|
|
|
async createTeam(name: string, description = ''): Promise<Team> {
|
|
return request<Team>('/api/v1/teams', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ name, description }),
|
|
});
|
|
},
|
|
|
|
async updateTeam(teamId: string, name: string, description = ''): Promise<Team> {
|
|
return request<Team>(`/api/v1/teams/${teamId}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ name, description }),
|
|
});
|
|
},
|
|
|
|
async deleteTeam(teamId: string): Promise<void> {
|
|
await request(`/api/v1/teams/${teamId}`, { method: 'DELETE' });
|
|
},
|
|
|
|
async getUserTeams(): Promise<Team[]> {
|
|
const res = await request<Team[]>('/api/v1/teams');
|
|
return ensureArray(res).map((t) => ({ ...t, members: ensureArray(t.members) }));
|
|
},
|
|
|
|
async addTeamMember(
|
|
teamId: string,
|
|
payload: { userId?: string; email?: string; role: string }
|
|
): Promise<void> {
|
|
await request(`/api/v1/teams/${teamId}/members`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
},
|
|
|
|
async updateTeamMemberRole(
|
|
teamId: string,
|
|
userId: string,
|
|
role: 'admin' | 'member'
|
|
): Promise<void> {
|
|
await request(`/api/v1/teams/${teamId}/members/${userId}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ role }),
|
|
});
|
|
},
|
|
|
|
async removeTeamMember(teamId: string, userId: string): Promise<void> {
|
|
await request(`/api/v1/teams/${teamId}/members/${userId}`, { method: 'DELETE' });
|
|
},
|
|
|
|
// --- ADMIN & STAFF PANEL ---
|
|
async adminGetUsers(): Promise<User[]> {
|
|
const res = await request<User[]>('/api/v1/admin/users');
|
|
return ensureArray(res);
|
|
},
|
|
|
|
async adminCreateUser(payload: {
|
|
email: string;
|
|
password: string;
|
|
displayName: string;
|
|
role: string;
|
|
}): Promise<User> {
|
|
return request<User>('/api/v1/admin/users', {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
},
|
|
|
|
async adminUpdateUser(
|
|
id: string,
|
|
patch: {
|
|
displayName?: string;
|
|
email?: string;
|
|
role?: string;
|
|
isActive?: boolean;
|
|
discordWebhookUrl?: string;
|
|
}
|
|
): Promise<User> {
|
|
return request<User>(`/api/v1/admin/users/${id}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(patch),
|
|
});
|
|
},
|
|
|
|
async adminResetPassword(id: string, password: string): Promise<void> {
|
|
await request(`/api/v1/admin/users/${id}/password`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ password }),
|
|
});
|
|
},
|
|
|
|
async adminDeleteUser(id: string): Promise<void> {
|
|
await request(`/api/v1/admin/users/${id}`, { method: 'DELETE' });
|
|
},
|
|
|
|
async adminGetInvites(): Promise<UserInvite[]> {
|
|
const res = await request<UserInvite[]>('/api/v1/admin/invites');
|
|
return ensureArray(res);
|
|
},
|
|
|
|
async adminCreateInvite(payload: {
|
|
email?: string;
|
|
role: string;
|
|
teamId?: string;
|
|
}): Promise<UserInvite> {
|
|
return request<UserInvite>('/api/v1/admin/invites', {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
},
|
|
|
|
async adminDeleteInvite(id: string): Promise<void> {
|
|
await request(`/api/v1/admin/invites/${id}`, { method: 'DELETE' });
|
|
},
|
|
|
|
async adminGetSettings(): Promise<SystemSettings> {
|
|
return request<SystemSettings>('/api/v1/admin/settings');
|
|
},
|
|
|
|
async adminUpdateSettings(settings: SystemSettings): Promise<SystemSettings> {
|
|
return request<SystemSettings>('/api/v1/admin/settings', {
|
|
method: 'PUT',
|
|
body: JSON.stringify(settings),
|
|
});
|
|
},
|
|
|
|
async adminTestSMTP(toEmail: string): Promise<{ status: string }> {
|
|
return request<{ status: string }>('/api/v1/admin/smtp/test', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ toEmail }),
|
|
});
|
|
},
|
|
|
|
// --- API KEYS, WEBHOOKS & EXPORT ---
|
|
async getAPIKeys(): Promise<APIKey[]> {
|
|
const res = await request<APIKey[]>('/api/v1/keys');
|
|
return ensureArray(res);
|
|
},
|
|
|
|
async createAPIKey(name: string): Promise<APIKey> {
|
|
return request<APIKey>('/api/v1/keys', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ name }),
|
|
});
|
|
},
|
|
|
|
async deleteAPIKey(id: string): Promise<void> {
|
|
await request(`/api/v1/keys/${id}`, { method: 'DELETE' });
|
|
},
|
|
|
|
async testWebhookIngest(
|
|
token: string,
|
|
title: string,
|
|
priority: PriorityLevel,
|
|
tags: string[]
|
|
): Promise<{ status: string; thought: Thought }> {
|
|
return request<{ status: string; thought: Thought }>(
|
|
`/api/v1/webhooks/ingest?token=${encodeURIComponent(token)}`,
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify({ title, priority, tags }),
|
|
}
|
|
);
|
|
},
|
|
|
|
async searchAll(q: string): Promise<{ thoughts: Thought[]; documents: Document[] }> {
|
|
return request<{ thoughts: Thought[]; documents: Document[] }>(
|
|
`/api/v1/search?q=${encodeURIComponent(q)}`
|
|
);
|
|
},
|
|
|
|
async exportAll(): Promise<any> {
|
|
return request<any>('/api/v1/export');
|
|
},
|
|
};
|
|
|
|
export function getOfflineQueue(): Thought[] {
|
|
try {
|
|
const raw = localStorage.getItem(OFFLINE_QUEUE_KEY);
|
|
if (!raw) return [];
|
|
return JSON.parse(raw);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export type RealtimeTransport = 'ws' | 'sse' | 'none';
|
|
|
|
let activeTransport: RealtimeTransport = 'none';
|
|
let activeGlobalWS: WebSocket | null = null;
|
|
let activeGlobalSSE: EventSource | null = null;
|
|
let activeSSEId: string | null = null;
|
|
let wsReconnectTimer: any = null;
|
|
let wsConnectionWatchdog: any = null;
|
|
let wsListeners: ((msg: WSMessage) => void)[] = [];
|
|
let realtimeStatusListeners: ((connected: boolean, transport: RealtimeTransport) => void)[] = [];
|
|
let isRealtimeConnectedState = false;
|
|
let manualDisconnect = false;
|
|
let wsReconnectDelay = 2000;
|
|
|
|
export function buildWebSocketURL(listId?: string): string {
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const token = getToken() || '';
|
|
if (listId) {
|
|
return `${protocol}//${window.location.host}/api/v1/ws/lists/${encodeURIComponent(listId)}?token=${encodeURIComponent(token)}`;
|
|
}
|
|
return `${protocol}//${window.location.host}/api/v1/ws?token=${encodeURIComponent(token)}`;
|
|
}
|
|
|
|
export function buildSSEURL(listId?: string, docId?: string, rooms?: string[]): string {
|
|
const token = getToken() || '';
|
|
const params = new URLSearchParams();
|
|
if (token) params.set('token', token);
|
|
if (listId) params.set('listId', listId);
|
|
if (docId) params.set('docId', docId);
|
|
if (rooms && rooms.length > 0) params.set('rooms', rooms.join(','));
|
|
return `/api/v1/events?${params.toString()}`;
|
|
}
|
|
|
|
export function isWebSocketConnected(): boolean {
|
|
return activeTransport === 'ws' && activeGlobalWS !== null && activeGlobalWS.readyState === WebSocket.OPEN;
|
|
}
|
|
|
|
export function isRealtimeConnected(): boolean {
|
|
return isRealtimeConnectedState;
|
|
}
|
|
|
|
export function getRealtimeTransport(): RealtimeTransport {
|
|
return activeTransport;
|
|
}
|
|
|
|
export function getSSEId(): string | null {
|
|
return activeSSEId;
|
|
}
|
|
|
|
export function onWebSocketStatus(callback: (connected: boolean) => void): () => void {
|
|
const wrapper = (connected: boolean) => callback(connected);
|
|
realtimeStatusListeners.push(wrapper);
|
|
callback(isRealtimeConnected());
|
|
return () => {
|
|
realtimeStatusListeners = realtimeStatusListeners.filter((fn) => fn !== wrapper);
|
|
};
|
|
}
|
|
|
|
export function onRealtimeStatus(callback: (connected: boolean, transport: RealtimeTransport) => void): () => void {
|
|
realtimeStatusListeners.push(callback);
|
|
callback(isRealtimeConnected(), activeTransport);
|
|
return () => {
|
|
realtimeStatusListeners = realtimeStatusListeners.filter((fn) => fn !== callback);
|
|
};
|
|
}
|
|
|
|
let isWsBlockedByProxy = false;
|
|
let sseReconnectTimer: any = null;
|
|
let sseReconnectDelay = 2000;
|
|
const globalSubscribedRooms = new Set<string>();
|
|
|
|
function notifyRealtimeStatusChange(connected: boolean, transport: RealtimeTransport) {
|
|
isRealtimeConnectedState = connected;
|
|
activeTransport = transport;
|
|
realtimeStatusListeners.forEach((fn) => {
|
|
try {
|
|
fn(connected, transport);
|
|
} catch (e) {
|
|
console.error('Realtime status listener error', e);
|
|
}
|
|
});
|
|
window.dispatchEvent(new CustomEvent('mindsift:ws:status', { detail: { connected, transport } }));
|
|
}
|
|
|
|
export function connectSSE(rooms?: string[]): void {
|
|
if (rooms) {
|
|
rooms.forEach((r) => globalSubscribedRooms.add(r));
|
|
}
|
|
clearTimeout(sseReconnectTimer);
|
|
clearTimeout(wsReconnectTimer);
|
|
clearTimeout(wsConnectionWatchdog);
|
|
|
|
if (activeGlobalSSE) {
|
|
try { activeGlobalSSE.close(); } catch {}
|
|
activeGlobalSSE = null;
|
|
}
|
|
|
|
try {
|
|
const url = buildSSEURL(undefined, undefined, Array.from(globalSubscribedRooms));
|
|
const es = new EventSource(url);
|
|
activeGlobalSSE = es;
|
|
|
|
es.onopen = () => {
|
|
console.info('[MindSift Realtime] Server-Sent Events (SSE) verbunden (Proxy-Fallback aktiv).');
|
|
sseReconnectDelay = 2000;
|
|
notifyRealtimeStatusChange(true, 'sse');
|
|
if (activeSSEId && globalSubscribedRooms.size > 0) {
|
|
globalSubscribedRooms.forEach((room) => {
|
|
api.joinSSERoom(activeSSEId!, room, 'join').catch(() => {});
|
|
});
|
|
}
|
|
};
|
|
|
|
es.onmessage = (event) => {
|
|
try {
|
|
const msg = JSON.parse(event.data) as WSMessage;
|
|
if (msg.event === 'SSE_CONNECTED') {
|
|
activeSSEId = msg.payload?.sseId || null;
|
|
if (activeSSEId && globalSubscribedRooms.size > 0) {
|
|
globalSubscribedRooms.forEach((room) => {
|
|
api.joinSSERoom(activeSSEId!, room, 'join').catch(() => {});
|
|
});
|
|
}
|
|
}
|
|
wsListeners.forEach((fn) => {
|
|
try {
|
|
fn(msg);
|
|
} catch (e) {
|
|
console.error('SSE listener error', e);
|
|
}
|
|
});
|
|
window.dispatchEvent(new CustomEvent('mindsift:ws', { detail: msg }));
|
|
} catch (err) {
|
|
console.error('Failed to parse SSE message', err);
|
|
}
|
|
};
|
|
|
|
es.onerror = (e) => {
|
|
if (activeTransport === 'sse') {
|
|
notifyRealtimeStatusChange(false, 'none');
|
|
}
|
|
try { es.close(); } catch {}
|
|
activeGlobalSSE = null;
|
|
if (!manualDisconnect && getToken()) {
|
|
clearTimeout(sseReconnectTimer);
|
|
const delay = sseReconnectDelay;
|
|
sseReconnectDelay = Math.min(30000, Math.round(sseReconnectDelay * 1.5));
|
|
sseReconnectTimer = setTimeout(() => {
|
|
if (!manualDisconnect && getToken()) {
|
|
if (isWsBlockedByProxy) {
|
|
connectSSE();
|
|
} else {
|
|
connectWebSocket();
|
|
}
|
|
}
|
|
}, delay);
|
|
}
|
|
};
|
|
} catch (err) {
|
|
console.error('Failed to initialize EventSource SSE', err);
|
|
notifyRealtimeStatusChange(false, 'none');
|
|
}
|
|
}
|
|
|
|
export function connectWebSocket(): void {
|
|
const token = getToken();
|
|
if (!token) return;
|
|
manualDisconnect = false;
|
|
|
|
// If proxy blocked WebSocket previously, do not attempt failing WebSocket; stay on SSE
|
|
if (isWsBlockedByProxy) {
|
|
connectSSE();
|
|
return;
|
|
}
|
|
|
|
if (activeGlobalWS && (activeGlobalWS.readyState === WebSocket.OPEN || activeGlobalWS.readyState === WebSocket.CONNECTING)) {
|
|
return;
|
|
}
|
|
|
|
clearTimeout(wsConnectionWatchdog);
|
|
clearTimeout(wsReconnectTimer);
|
|
clearTimeout(sseReconnectTimer);
|
|
|
|
try {
|
|
const url = buildWebSocketURL();
|
|
const ws = new WebSocket(url);
|
|
activeGlobalWS = ws;
|
|
|
|
// 3.5s watchdog for corporate proxies silently stripping Upgrade header
|
|
wsConnectionWatchdog = setTimeout(() => {
|
|
if (ws && ws.readyState !== WebSocket.OPEN) {
|
|
console.warn('[MindSift Realtime] WebSocket connection timeout (Proxy Upgrade Block). Wechsle automatisch zu SSE...');
|
|
isWsBlockedByProxy = true;
|
|
try { ws.close(); } catch {}
|
|
activeGlobalWS = null;
|
|
connectSSE();
|
|
}
|
|
}, 3500);
|
|
|
|
ws.onopen = () => {
|
|
clearTimeout(wsConnectionWatchdog);
|
|
if (activeGlobalSSE) {
|
|
try { activeGlobalSSE.close(); } catch {}
|
|
activeGlobalSSE = null;
|
|
}
|
|
wsReconnectDelay = 2000;
|
|
notifyRealtimeStatusChange(true, 'ws');
|
|
if (globalSubscribedRooms.size > 0) {
|
|
globalSubscribedRooms.forEach((room) => {
|
|
try {
|
|
ws.send(JSON.stringify({ event: 'JOIN_ROOM', room }));
|
|
} catch {}
|
|
});
|
|
}
|
|
};
|
|
|
|
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 = (evt: CloseEvent) => {
|
|
clearTimeout(wsConnectionWatchdog);
|
|
activeGlobalWS = null;
|
|
|
|
const isProxyIssue = evt?.code === 1006 || evt?.code === 1002 || evt?.code === 1008;
|
|
if (isProxyIssue && !manualDisconnect && getToken()) {
|
|
console.warn('[MindSift Realtime] Proxy/Firewall Inkompatibilität erkannt (Code ' + evt.code + '). Wechsle dauerhaft zu SSE...');
|
|
isWsBlockedByProxy = true;
|
|
connectSSE();
|
|
return;
|
|
}
|
|
|
|
if (activeTransport === 'ws') {
|
|
notifyRealtimeStatusChange(false, 'none');
|
|
}
|
|
|
|
if (!manualDisconnect && getToken() && activeTransport !== 'sse') {
|
|
clearTimeout(wsReconnectTimer);
|
|
const delay = wsReconnectDelay;
|
|
wsReconnectDelay = Math.min(30000, Math.round(wsReconnectDelay * 1.5));
|
|
wsReconnectTimer = setTimeout(() => {
|
|
if (!manualDisconnect && getToken()) {
|
|
connectWebSocket();
|
|
}
|
|
}, delay);
|
|
}
|
|
};
|
|
|
|
ws.onerror = (e) => {
|
|
clearTimeout(wsConnectionWatchdog);
|
|
try {
|
|
ws.close();
|
|
} catch {}
|
|
};
|
|
} catch (e) {
|
|
clearTimeout(wsConnectionWatchdog);
|
|
isWsBlockedByProxy = true;
|
|
connectSSE();
|
|
}
|
|
}
|
|
|
|
// Ensure reconnect when browser recovers from sleep or tab visibility
|
|
if (typeof window !== 'undefined') {
|
|
window.addEventListener('online', () => {
|
|
if (getToken() && !isRealtimeConnected()) {
|
|
if (isWsBlockedByProxy) {
|
|
connectSSE();
|
|
} else {
|
|
connectWebSocket();
|
|
}
|
|
}
|
|
});
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.visibilityState === 'visible' && getToken() && !isRealtimeConnected()) {
|
|
if (isWsBlockedByProxy) {
|
|
connectSSE();
|
|
} else {
|
|
connectWebSocket();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
export function disconnectWebSocket(): void {
|
|
manualDisconnect = true;
|
|
clearTimeout(wsReconnectTimer);
|
|
clearTimeout(wsConnectionWatchdog);
|
|
clearTimeout(sseReconnectTimer);
|
|
if (activeGlobalWS) {
|
|
try {
|
|
activeGlobalWS.close();
|
|
} catch {}
|
|
activeGlobalWS = null;
|
|
}
|
|
if (activeGlobalSSE) {
|
|
try {
|
|
activeGlobalSSE.close();
|
|
} catch {}
|
|
activeGlobalSSE = null;
|
|
}
|
|
notifyRealtimeStatusChange(false, 'none');
|
|
}
|
|
|
|
export function sendWebSocketMessage(msg: any): void {
|
|
if (activeGlobalWS && activeGlobalWS.readyState === WebSocket.OPEN) {
|
|
activeGlobalWS.send(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
|
return;
|
|
}
|
|
|
|
// SSE Fallback: dispatch via REST endpoints
|
|
try {
|
|
const parsed = typeof msg === 'string' ? JSON.parse(msg) : msg;
|
|
if (parsed.event === 'JOIN_ROOM' && parsed.room) {
|
|
if (activeSSEId) api.joinSSERoom(activeSSEId, parsed.room, 'join').catch(() => {});
|
|
} else if (parsed.event === 'LEAVE_ROOM' && parsed.room) {
|
|
if (activeSSEId) api.joinSSERoom(activeSSEId, parsed.room, 'leave').catch(() => {});
|
|
}
|
|
} catch (err) {
|
|
console.error('Error dispatching fallback REST message for SSE:', err);
|
|
}
|
|
}
|
|
|
|
export function onWebSocketMessage(callback: (msg: WSMessage) => void): () => void {
|
|
wsListeners.push(callback);
|
|
return () => {
|
|
wsListeners = wsListeners.filter((fn) => fn !== callback);
|
|
};
|
|
}
|
|
|
|
export function createRealtimeConnection(options: {
|
|
listId?: string;
|
|
docId?: string;
|
|
rooms?: string[];
|
|
onMessage?: (msg: WSMessage) => void;
|
|
onStatusChange?: (status: { connected: boolean; transport: RealtimeTransport; reason?: string }) => void;
|
|
}) {
|
|
let isClosed = false;
|
|
|
|
const joinedRooms: string[] = [];
|
|
if (options.docId) joinedRooms.push(`doc:${options.docId}`);
|
|
if (options.listId) joinedRooms.push(options.listId);
|
|
if (options.rooms) {
|
|
options.rooms.forEach((r) => {
|
|
if (!joinedRooms.includes(r)) joinedRooms.push(r);
|
|
});
|
|
}
|
|
|
|
joinedRooms.forEach((r) => globalSubscribedRooms.add(r));
|
|
|
|
// Subscribe to status changes
|
|
const unsubStatus = onRealtimeStatus((connected, transport) => {
|
|
if (isClosed) return;
|
|
options.onStatusChange?.({
|
|
connected,
|
|
transport,
|
|
reason: connected ? undefined : 'Verbindung getrennt',
|
|
});
|
|
});
|
|
|
|
// Subscribe to messages
|
|
let unsubMsg: (() => void) | null = null;
|
|
if (options.onMessage) {
|
|
unsubMsg = onWebSocketMessage((msg) => {
|
|
if (isClosed) return;
|
|
options.onMessage?.(msg);
|
|
});
|
|
}
|
|
|
|
// Join rooms on the active connection
|
|
const joinAll = () => {
|
|
joinedRooms.forEach((r) => {
|
|
if (r.startsWith('doc:')) {
|
|
sendWebSocketMessage({ event: 'CRDT_JOIN', docId: r.slice(4) });
|
|
} else {
|
|
sendWebSocketMessage({ event: 'JOIN_ROOM', room: r });
|
|
}
|
|
});
|
|
};
|
|
|
|
if (isRealtimeConnected()) {
|
|
joinAll();
|
|
}
|
|
|
|
const unsubRejoin = onRealtimeStatus((connected) => {
|
|
if (isClosed) return;
|
|
if (connected) {
|
|
joinAll();
|
|
}
|
|
});
|
|
|
|
return {
|
|
getTransport: () => activeTransport,
|
|
isConnected: () => isRealtimeConnected(),
|
|
send: (msg: any) => {
|
|
if (isClosed) return;
|
|
sendWebSocketMessage(msg);
|
|
},
|
|
close: () => {
|
|
isClosed = true;
|
|
unsubStatus();
|
|
unsubRejoin();
|
|
if (unsubMsg) unsubMsg();
|
|
joinedRooms.forEach((r) => {
|
|
globalSubscribedRooms.delete(r);
|
|
if (r.startsWith('doc:')) {
|
|
sendWebSocketMessage({ event: 'CRDT_LEAVE', docId: r.slice(4) });
|
|
} else {
|
|
sendWebSocketMessage({ event: 'LEAVE_ROOM', room: r });
|
|
}
|
|
});
|
|
},
|
|
};
|
|
}
|