|
|
|
@@ -18,6 +18,7 @@ export interface Project {
|
|
|
|
|
name: string;
|
|
|
|
|
description: string | null;
|
|
|
|
|
owner_id: string;
|
|
|
|
|
folder_id: string | null;
|
|
|
|
|
created_at: string;
|
|
|
|
|
updated_at: string;
|
|
|
|
|
}
|
|
|
|
@@ -30,8 +31,32 @@ export interface Drawing {
|
|
|
|
|
updated_at: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Auth Types ────────────────────────────────────────
|
|
|
|
|
export interface AuthUser {
|
|
|
|
|
id: string;
|
|
|
|
|
email: string;
|
|
|
|
|
name: string;
|
|
|
|
|
role: string;
|
|
|
|
|
created_at: string;
|
|
|
|
|
updated_at: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface AuthSession {
|
|
|
|
|
token: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface LoginResponse {
|
|
|
|
|
user: AuthUser;
|
|
|
|
|
session: AuthSession;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface RegisterResponse {
|
|
|
|
|
user: AuthUser;
|
|
|
|
|
session: AuthSession;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Auth ───────────────────────────────────────────────
|
|
|
|
|
export async function login(email: string, password: string): Promise<{ user: any; session: { token: string } }> {
|
|
|
|
|
export async function login(email: string, password: string): Promise<LoginResponse> {
|
|
|
|
|
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
@@ -41,7 +66,7 @@ export async function login(email: string, password: string): Promise<{ user: an
|
|
|
|
|
return res.json();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function register(email: string, password: string, name: string): Promise<{ user: any; session: { token: string } }> {
|
|
|
|
|
export async function register(email: string, password: string, name: string): Promise<RegisterResponse> {
|
|
|
|
|
const res = await fetch(`${API_BASE}/api/auth/register`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
@@ -51,7 +76,7 @@ export async function register(email: string, password: string, name: string): P
|
|
|
|
|
return res.json();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getMe(token: string): Promise<any> {
|
|
|
|
|
export async function getMe(token: string): Promise<AuthUser> {
|
|
|
|
|
const res = await fetch(`${API_BASE}/api/auth/me`, {
|
|
|
|
|
headers: authHeaders(token),
|
|
|
|
|
});
|
|
|
|
@@ -83,6 +108,78 @@ export async function deleteProject(token: string, id: string): Promise<void> {
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function updateProject(token: string, id: string, updates: { name?: string; description?: string | null }): Promise<Project> {
|
|
|
|
|
const res = await fetch(`${API_BASE}/api/projects/${id}`, {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
headers: authHeaders(token),
|
|
|
|
|
body: JSON.stringify(updates),
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) throw new Error('Failed to update project');
|
|
|
|
|
return res.json();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Project Folders ─────────────────────────────────────
|
|
|
|
|
export interface ProjectFolder {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
parent_id: string | null;
|
|
|
|
|
owner_id: string;
|
|
|
|
|
created_at: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getProjectFolders(token: string): Promise<ProjectFolder[]> {
|
|
|
|
|
const res = await fetch(`${API_BASE}/api/project-folders`, { headers: authHeaders(token) });
|
|
|
|
|
if (!res.ok) throw new Error('Failed to load project folders');
|
|
|
|
|
return res.json();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function createProjectFolder(token: string, name: string, parentId?: string | null): Promise<ProjectFolder> {
|
|
|
|
|
const res = await fetch(`${API_BASE}/api/project-folders`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: authHeaders(token),
|
|
|
|
|
body: JSON.stringify({ name, parent_id: parentId ?? null }),
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) throw new Error('Failed to create project folder');
|
|
|
|
|
return res.json();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function renameProjectFolder(token: string, id: string, name: string): Promise<ProjectFolder> {
|
|
|
|
|
const res = await fetch(`${API_BASE}/api/project-folders/${id}`, {
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
headers: authHeaders(token),
|
|
|
|
|
body: JSON.stringify({ name }),
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) throw new Error('Failed to rename project folder');
|
|
|
|
|
return res.json();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function deleteProjectFolder(token: string, id: string): Promise<void> {
|
|
|
|
|
const res = await fetch(`${API_BASE}/api/project-folders/${id}`, {
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
headers: authHeaders(token),
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) throw new Error('Failed to delete project folder');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function moveProjectToFolder(token: string, projectId: string, folderId: string | null): Promise<Project> {
|
|
|
|
|
const res = await fetch(`${API_BASE}/api/projects/${projectId}/folder`, {
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
headers: authHeaders(token),
|
|
|
|
|
body: JSON.stringify({ folder_id: folderId }),
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) throw new Error('Failed to move project to folder');
|
|
|
|
|
return res.json();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getProjectsByFolder(token: string, folderId: string | null): Promise<Project[]> {
|
|
|
|
|
const url = folderId === null
|
|
|
|
|
? `${API_BASE}/api/projects?folderId=null`
|
|
|
|
|
: `${API_BASE}/api/projects?folderId=${folderId}`;
|
|
|
|
|
const res = await fetch(url, { headers: authHeaders(token) });
|
|
|
|
|
if (!res.ok) throw new Error('Failed to load projects by folder');
|
|
|
|
|
return res.json();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Drawings ───────────────────────────────────────────
|
|
|
|
|
export async function getDrawings(token: string, projectId: string): Promise<Drawing[]> {
|
|
|
|
|
const res = await fetch(`${API_BASE}/api/projects/${projectId}/drawings`, { headers: authHeaders(token) });
|
|
|
|
@@ -349,12 +446,20 @@ export async function createBlockTyped(token: string, drawingId: string, block:
|
|
|
|
|
return dbBlockToFrontend(raw);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const projectLoadCache = new Map<string, Promise<ProjectData>>();
|
|
|
|
|
const projectLoadCache = new Map<string, { promise: Promise<ProjectData>; requestId: number }>();
|
|
|
|
|
const projectLoadRequestCounter = new Map<string, number>();
|
|
|
|
|
|
|
|
|
|
export async function loadProjectDataTyped(token: string, projectId: string): Promise<ProjectData> {
|
|
|
|
|
// Dedup concurrent calls (React StrictMode double-render)
|
|
|
|
|
// Generate a new request ID to track the latest request for this project
|
|
|
|
|
const currentRequestId = (projectLoadRequestCounter.get(projectId) ?? 0) + 1;
|
|
|
|
|
projectLoadRequestCounter.set(projectId, currentRequestId);
|
|
|
|
|
|
|
|
|
|
// Dedup concurrent calls (React StrictMode double-render): if there is an in-flight
|
|
|
|
|
// request with the same ID, reuse it. Otherwise start a fresh one.
|
|
|
|
|
const existing = projectLoadCache.get(projectId);
|
|
|
|
|
if (existing) return existing;
|
|
|
|
|
if (existing && existing.requestId === currentRequestId - 1) {
|
|
|
|
|
return existing.promise;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const promise = (async () => {
|
|
|
|
|
const drawings = await getDrawings(token, projectId);
|
|
|
|
@@ -373,6 +478,7 @@ export async function loadProjectDataTyped(token: string, projectId: string): Pr
|
|
|
|
|
getBlocks(token, drawing.id),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// Return data even if a newer request exists (React StrictMode double-render safe)
|
|
|
|
|
return {
|
|
|
|
|
project,
|
|
|
|
|
drawing,
|
|
|
|
@@ -382,8 +488,14 @@ export async function loadProjectDataTyped(token: string, projectId: string): Pr
|
|
|
|
|
};
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
projectLoadCache.set(projectId, promise);
|
|
|
|
|
promise.finally(() => projectLoadCache.delete(projectId));
|
|
|
|
|
projectLoadCache.set(projectId, { promise, requestId: currentRequestId });
|
|
|
|
|
promise.finally(() => {
|
|
|
|
|
// Only clear cache if this is still the latest request
|
|
|
|
|
const cached = projectLoadCache.get(projectId);
|
|
|
|
|
if (cached && cached.requestId === currentRequestId) {
|
|
|
|
|
projectLoadCache.delete(projectId);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
return promise;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -465,8 +577,131 @@ export async function deleteProjectShare(token: string, shareId: string): Promis
|
|
|
|
|
if (!res.ok) throw new Error('Failed to delete share');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Global Block Folders ───────────────────────────────────
|
|
|
|
|
export interface GlobalBlockFolder {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
parent_id: string | null;
|
|
|
|
|
created_at: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getGlobalFolders(token: string, parentId?: string | null): Promise<GlobalBlockFolder[]> {
|
|
|
|
|
const url = parentId !== undefined
|
|
|
|
|
? `${API_BASE}/api/global-folders?parentId=${parentId === null ? 'null' : parentId}`
|
|
|
|
|
: `${API_BASE}/api/global-folders`;
|
|
|
|
|
const res = await fetch(url, { headers: authHeaders(token) });
|
|
|
|
|
if (!res.ok) throw new Error('Failed to load global folders');
|
|
|
|
|
return res.json();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function createGlobalFolder(token: string, name: string, parentId?: string | null): Promise<GlobalBlockFolder> {
|
|
|
|
|
const res = await fetch(`${API_BASE}/api/global-folders`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: authHeaders(token),
|
|
|
|
|
body: JSON.stringify({ name, parent_id: parentId ?? null }),
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) throw new Error('Failed to create global folder');
|
|
|
|
|
return res.json();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function renameGlobalFolder(token: string, id: string, name: string): Promise<GlobalBlockFolder> {
|
|
|
|
|
const res = await fetch(`${API_BASE}/api/global-folders/${id}`, {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
headers: authHeaders(token),
|
|
|
|
|
body: JSON.stringify({ name }),
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) throw new Error('Failed to rename global folder');
|
|
|
|
|
return res.json();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function deleteGlobalFolder(token: string, id: string): Promise<void> {
|
|
|
|
|
await fetch(`${API_BASE}/api/global-folders/${id}`, {
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
headers: authHeaders(token),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Global Blocks ────────────────────────────────────────
|
|
|
|
|
export interface GlobalBlock {
|
|
|
|
|
id: string;
|
|
|
|
|
folder_id: string | null;
|
|
|
|
|
name: string;
|
|
|
|
|
block_data: string;
|
|
|
|
|
svg_data: string | null;
|
|
|
|
|
created_at: string;
|
|
|
|
|
updated_at: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getGlobalBlocks(token: string, folderId?: string | null): Promise<GlobalBlock[]> {
|
|
|
|
|
const url = folderId !== undefined
|
|
|
|
|
? `${API_BASE}/api/global-blocks?folderId=${folderId === null ? 'null' : folderId}`
|
|
|
|
|
: `${API_BASE}/api/global-blocks`;
|
|
|
|
|
const res = await fetch(url, { headers: authHeaders(token) });
|
|
|
|
|
if (!res.ok) throw new Error('Failed to load global blocks');
|
|
|
|
|
return res.json();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function createGlobalBlock(token: string, data: {
|
|
|
|
|
name: string;
|
|
|
|
|
folder_id?: string | null;
|
|
|
|
|
block_data?: string;
|
|
|
|
|
svg_data?: string;
|
|
|
|
|
}): Promise<GlobalBlock> {
|
|
|
|
|
const res = await fetch(`${API_BASE}/api/global-blocks`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: authHeaders(token),
|
|
|
|
|
body: JSON.stringify(data),
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) throw new Error('Failed to create global block');
|
|
|
|
|
return res.json();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function renameGlobalBlock(token: string, id: string, name: string): Promise<GlobalBlock> {
|
|
|
|
|
const res = await fetch(`${API_BASE}/api/global-blocks/${id}`, {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
headers: authHeaders(token),
|
|
|
|
|
body: JSON.stringify({ name }),
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) throw new Error('Failed to rename global block');
|
|
|
|
|
return res.json();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function deleteGlobalBlock(token: string, id: string): Promise<void> {
|
|
|
|
|
await fetch(`${API_BASE}/api/global-blocks/${id}`, {
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
headers: authHeaders(token),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export { API_BASE };
|
|
|
|
|
|
|
|
|
|
// ─── Settings (Key/Value) ────────────────────────────────
|
|
|
|
|
export interface Setting {
|
|
|
|
|
key: string;
|
|
|
|
|
value: string;
|
|
|
|
|
updated_at: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getSetting(token: string, key: string): Promise<Setting | null> {
|
|
|
|
|
const res = await fetch(`${API_BASE}/api/settings/${encodeURIComponent(key)}`, {
|
|
|
|
|
method: 'GET',
|
|
|
|
|
headers: authHeaders(token),
|
|
|
|
|
});
|
|
|
|
|
if (res.status === 404) return null;
|
|
|
|
|
if (!res.ok) throw new Error(`Failed to fetch setting: ${key}`);
|
|
|
|
|
return res.json();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function setSetting(token: string, key: string, value: string): Promise<Setting> {
|
|
|
|
|
const res = await fetch(`${API_BASE}/api/settings/${encodeURIComponent(key)}`, {
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
headers: authHeaders(token),
|
|
|
|
|
body: JSON.stringify({ value }),
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) throw new Error(`Failed to save setting: ${key}`);
|
|
|
|
|
return res.json();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── AI Copilot ─────────────────────────────────────────
|
|
|
|
|
export interface AIChatMessage {
|
|
|
|
|
role: string;
|
|
|
|
|