Skip to content

Commit 3800e83

Browse files
committed
Feat(web): Extracting public APIs
1 parent ebf58f0 commit 3800e83

32 files changed

Lines changed: 484 additions & 411 deletions

apps/web/src/auth/authApi.ts

Lines changed: 4 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
1-
export type ApiErrorPayload = {
2-
error?: string;
3-
code?: string;
4-
message?: string;
5-
details?: unknown;
6-
};
1+
import { apiRequest, type ApiErrorPayload, ApiError } from '@/infra/api';
2+
3+
export { ApiError, type ApiErrorPayload };
74

85
export type PublicUser = {
96
id: string;
@@ -19,63 +16,10 @@ export type AuthResponse = {
1916
expiresAt: string;
2017
};
2118

22-
export class ApiError extends Error {
23-
status: number;
24-
code?: string;
25-
details?: unknown;
26-
27-
constructor(
28-
message: string,
29-
status: number,
30-
code?: string,
31-
details?: unknown
32-
) {
33-
super(message);
34-
this.name = 'ApiError';
35-
this.status = status;
36-
this.code = code;
37-
this.details = details;
38-
}
39-
}
40-
41-
const resolveBaseUrl = () => {
42-
const base = (import.meta as ImportMeta & { env?: Record<string, string> })
43-
.env?.VITE_API_BASE;
44-
if (base && base.trim()) {
45-
return base.replace(/\/+$/, '');
46-
}
47-
return 'http://localhost:8080';
48-
};
49-
50-
const API_ROOT = `${resolveBaseUrl()}/api`;
51-
5219
const request = async <T>(
5320
path: string,
5421
options: RequestInit = {}
55-
): Promise<T> => {
56-
const response = await fetch(`${API_ROOT}${path}`, options);
57-
if (response.status === 204) {
58-
return undefined as T;
59-
}
60-
const contentType = response.headers.get('content-type') || '';
61-
const isJson = contentType.includes('application/json');
62-
const payload = isJson ? await response.json() : await response.text();
63-
if (!response.ok) {
64-
const apiPayload = payload as ApiErrorPayload;
65-
const message =
66-
(typeof apiPayload === 'object' && apiPayload?.message) ||
67-
response.statusText ||
68-
'Request failed.';
69-
const code = typeof apiPayload === 'object' ? apiPayload?.error : undefined;
70-
throw new ApiError(
71-
message,
72-
response.status,
73-
apiPayload?.code || code,
74-
apiPayload?.details
75-
);
76-
}
77-
return payload as T;
78-
};
22+
): Promise<T> => apiRequest<T>(path, options);
7923

8024
export const authApi = {
8125
register: async (data: {

apps/web/src/community/communityApi.ts

Lines changed: 6 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ApiError } from '@/auth/authApi';
1+
import { apiRequest } from '@/infra/api';
22

33
export type CommunityResourceType = 'project' | 'component' | 'nodegraph';
44

@@ -36,53 +36,12 @@ type ListProjectsOptions = {
3636
pageSize?: number;
3737
};
3838

39-
const resolveBaseUrl = () => {
40-
const base = (import.meta as ImportMeta & { env?: Record<string, string> })
41-
.env?.VITE_API_BASE;
42-
if (base && base.trim()) {
43-
return base.replace(/\/+$/, '');
44-
}
45-
return 'http://localhost:8080';
46-
};
47-
48-
const API_ROOT = `${resolveBaseUrl()}/api`;
39+
const JSON_HEADERS = {
40+
'Content-Type': 'application/json',
41+
} as const;
4942

50-
const request = async <T>(path: string): Promise<T> => {
51-
const response = await fetch(`${API_ROOT}${path}`, {
52-
headers: {
53-
'Content-Type': 'application/json',
54-
},
55-
});
56-
if (response.status === 204) {
57-
return undefined as T;
58-
}
59-
const contentType = response.headers.get('content-type') || '';
60-
const isJson = contentType.includes('application/json');
61-
const payload = isJson ? await response.json() : await response.text();
62-
if (!response.ok) {
63-
const message =
64-
typeof payload === 'object' && payload && 'message' in payload
65-
? String((payload as { message?: string }).message || '')
66-
: response.statusText || 'Request failed.';
67-
const code =
68-
typeof payload === 'object' && payload && 'code' in payload
69-
? String((payload as { code?: string }).code || '')
70-
: typeof payload === 'object' && payload && 'error' in payload
71-
? String((payload as { error?: string }).error || '')
72-
: undefined;
73-
const details =
74-
typeof payload === 'object' && payload && 'details' in payload
75-
? (payload as { details?: unknown }).details
76-
: undefined;
77-
throw new ApiError(
78-
message || 'Request failed.',
79-
response.status,
80-
code,
81-
details
82-
);
83-
}
84-
return payload as T;
85-
};
43+
const request = async <T>(path: string): Promise<T> =>
44+
apiRequest<T>(path, { defaultHeaders: JSON_HEADERS });
8645

8746
const buildListQuery = (options: ListProjectsOptions) => {
8847
const params = new URLSearchParams();

apps/web/src/editor/Editor.tsx

Lines changed: 18 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { mountGraphExecutionBridge } from '@/core/executor/executor';
77
import { editorApi } from './editorApi';
88
import { useEditorStore } from './store/useEditorStore';
99
import { useSettingsStore } from './store/useSettingsStore';
10+
import { isEditableTarget, useWindowKeydown } from '@/shortcuts';
1011

1112
function Editor() {
1213
const { projectId } = useParams();
@@ -96,46 +97,33 @@ function Editor() {
9697

9798
useEffect(() => mountGraphExecutionBridge(), []);
9899

99-
useEffect(() => {
100-
if (!projectId) return;
101-
102-
const isEditableTarget = (target: EventTarget | null) => {
103-
if (!(target instanceof HTMLElement)) return false;
104-
if (target.isContentEditable) return true;
105-
const tagName = target.tagName.toLowerCase();
106-
return (
107-
tagName === 'input' || tagName === 'textarea' || tagName === 'select'
108-
);
109-
};
110-
111-
const routeByDigit: Record<string, string> = {
112-
'1': `/editor/project/${projectId}`,
113-
'2': `/editor/project/${projectId}/blueprint`,
114-
'3': `/editor/project/${projectId}/nodegraph`,
115-
'4': `/editor/project/${projectId}/animation`,
116-
'5': `/editor/project/${projectId}/component`,
117-
'6': `/editor/project/${projectId}/resources`,
118-
'7': `/editor/project/${projectId}/test`,
119-
'8': `/editor/project/${projectId}/export`,
120-
'9': `/editor/project/${projectId}/deployment`,
121-
};
122-
123-
const onKeyDown = (event: globalThis.KeyboardEvent) => {
100+
useWindowKeydown(
101+
(event) => {
102+
if (!projectId) return;
124103
if (event.defaultPrevented) return;
125104
if (isEditableTarget(event.target)) return;
126105
if (!event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
127106
return;
128107
}
108+
const routeByDigit: Record<string, string> = {
109+
'1': `/editor/project/${projectId}`,
110+
'2': `/editor/project/${projectId}/blueprint`,
111+
'3': `/editor/project/${projectId}/nodegraph`,
112+
'4': `/editor/project/${projectId}/animation`,
113+
'5': `/editor/project/${projectId}/component`,
114+
'6': `/editor/project/${projectId}/resources`,
115+
'7': `/editor/project/${projectId}/test`,
116+
'8': `/editor/project/${projectId}/export`,
117+
'9': `/editor/project/${projectId}/deployment`,
118+
};
129119
const nextPath = routeByDigit[event.key];
130120
if (!nextPath) return;
131121
if (location.pathname === nextPath) return;
132122
event.preventDefault();
133123
navigate(nextPath);
134-
};
135-
136-
window.addEventListener('keydown', onKeyDown);
137-
return () => window.removeEventListener('keydown', onKeyDown);
138-
}, [location.pathname, navigate, projectId]);
124+
},
125+
{ enabled: Boolean(projectId) }
126+
);
139127

140128
return (
141129
<div className="flex min-h-screen max-h-screen flex-row bg-[linear-gradient(120deg,var(--color-0)_20%,var(--color-1)_100%)]">

apps/web/src/editor/EditorBar/EditorBar.tsx

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useState } from 'react';
1+
import { useState } from 'react';
22
import { useTranslation } from 'react-i18next';
33
import { MdrIcon, MdrIconLink } from '@mdr/ui';
44
import { useLocation, useNavigate, useParams } from 'react-router';
@@ -17,6 +17,7 @@ import {
1717
Home,
1818
} from 'lucide-react';
1919
import { EditorBarExitModal } from './EditorBarExitModal';
20+
import { hasModifierKey, useWindowKeydown } from '@/shortcuts';
2021

2122
function EditorBar() {
2223
const { t } = useTranslation(['editor', 'routes']);
@@ -37,19 +38,15 @@ function EditorBar() {
3738
'flex flex-col items-center gap-[14px] [&_.MdrIconLink]:!text-[var(--color-9)] [&_.MdrIconLink:hover]:!text-[var(--color-10)]';
3839
const isBlueprintRoute = location.pathname.includes('/blueprint');
3940

40-
useEffect(() => {
41-
if (!isBlueprintRoute) return;
42-
const onKeyDown = (event: globalThis.KeyboardEvent) => {
41+
useWindowKeydown(
42+
(event) => {
4343
if (event.defaultPrevented) return;
4444
if (event.key !== 'Escape') return;
45-
if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) {
46-
return;
47-
}
45+
if (hasModifierKey(event)) return;
4846
setExitOpen(true);
49-
};
50-
window.addEventListener('keydown', onKeyDown);
51-
return () => window.removeEventListener('keydown', onKeyDown);
52-
}, [isBlueprintRoute]);
47+
},
48+
{ enabled: isBlueprintRoute }
49+
);
5350

5451
return (
5552
<>

apps/web/src/editor/EditorBar/EditorBarExitModal.tsx

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { useEffect } from 'react';
21
import { CornerDownLeft, Delete } from 'lucide-react';
32
import { MdrButton } from '@mdr/ui';
3+
import { hasModifierKey, useWindowKeydown } from '@/shortcuts';
44

55
type EditorBarExitModalProps = {
66
isOpen: boolean;
@@ -21,13 +21,10 @@ export function EditorBarExitModal({
2121
onClose,
2222
onConfirm,
2323
}: EditorBarExitModalProps) {
24-
useEffect(() => {
25-
if (!isOpen) return;
26-
const onKeyDown = (event: globalThis.KeyboardEvent) => {
24+
useWindowKeydown(
25+
(event) => {
2726
if (event.defaultPrevented) return;
28-
if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) {
29-
return;
30-
}
27+
if (hasModifierKey(event)) return;
3128
if (event.key === 'Backspace' || event.key === 'Escape') {
3229
event.preventDefault();
3330
onClose();
@@ -37,10 +34,9 @@ export function EditorBarExitModal({
3734
event.preventDefault();
3835
onConfirm();
3936
}
40-
};
41-
window.addEventListener('keydown', onKeyDown);
42-
return () => window.removeEventListener('keydown', onKeyDown);
43-
}, [isOpen, onClose, onConfirm]);
37+
},
38+
{ enabled: isOpen }
39+
);
4440

4541
if (!isOpen) return null;
4642

apps/web/src/editor/EditorHome.tsx

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import NewResourceModal from './features/newfile/NewResourceModal';
2020
import { editorApi, type ProjectSummary } from './editorApi';
2121
import { useAuthStore } from '@/auth/useAuthStore';
2222
import { useEditorStore } from './store/useEditorStore';
23+
import { hasModifierKey, useWindowKeydown } from '@/shortcuts';
2324

2425
function EditorTipsRandom() {
2526
const { t } = useTranslation('editor');
@@ -315,20 +316,16 @@ function EditorHome() {
315316
const setProjectInStore = useEditorStore((state) => state.setProject);
316317
const removeProjectInStore = useEditorStore((state) => state.removeProject);
317318

318-
useEffect(() => {
319-
if (isResourceModalOpen || isExitModalOpen) return;
320-
const onKeyDown = (event: globalThis.KeyboardEvent) => {
319+
useWindowKeydown(
320+
(event) => {
321321
if (event.defaultPrevented) return;
322322
if (event.key !== 'Escape') return;
323-
if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) {
324-
return;
325-
}
323+
if (hasModifierKey(event)) return;
326324
event.preventDefault();
327325
setExitModalOpen(true);
328-
};
329-
window.addEventListener('keydown', onKeyDown);
330-
return () => window.removeEventListener('keydown', onKeyDown);
331-
}, [isResourceModalOpen, isExitModalOpen]);
326+
},
327+
{ enabled: !isResourceModalOpen && !isExitModalOpen }
328+
);
332329

333330
useEffect(() => {
334331
if (!token) {

0 commit comments

Comments
 (0)