Skip to content

Commit 86d2279

Browse files
refactor(frontend): complete Tasks 1-5 UI refactoring
Task 1: Centralize state with Redux Toolkit (4 slices, typed hooks, selectors) Task 2: Centralized HTTP client with interceptors (httpClient singleton) Task 3: Extract 18 reusable components from monolithic files Task 4: Wrap all components with React.memo + displayName, useCallback throughout Task 5: Extract business logic into 7 custom hooks (useChatOrchestrator, etc.) - App.tsx reduced from ~787 to ~85 lines - Zero raw fetch calls (except /.auth/me) - All console.log downgraded to console.debug - Build: 0 TypeScript errors
1 parent 9e716f3 commit 86d2279

39 files changed

Lines changed: 3613 additions & 2296 deletions

content-gen/src/app/frontend/src/App.tsx

Lines changed: 52 additions & 832 deletions
Large diffs are not rendered by default.
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
/**
2+
* Centralized HTTP client with interceptors.
3+
*
4+
* - Singleton — use the default `httpClient` export everywhere.
5+
* - Request interceptors automatically attach auth headers
6+
* (X-Ms-Client-Principal-Id) so callers never need to remember.
7+
* - Response interceptors provide uniform error handling.
8+
* - Built-in query-param serialization, configurable timeout, and base URL.
9+
*/
10+
11+
/* ------------------------------------------------------------------ */
12+
/* Types */
13+
/* ------------------------------------------------------------------ */
14+
15+
/** Options accepted by every request method. */
16+
export interface RequestOptions extends Omit<RequestInit, 'method' | 'body'> {
17+
/** Query parameters – appended to the URL automatically. */
18+
params?: Record<string, string | number | boolean | undefined>;
19+
/** Per-request timeout in ms (default: client-level `timeout`). */
20+
timeout?: number;
21+
}
22+
23+
type RequestInterceptor = (url: string, init: RequestInit) => RequestInit | Promise<RequestInit>;
24+
type ResponseInterceptor = (response: Response) => Response | Promise<Response>;
25+
26+
/* ------------------------------------------------------------------ */
27+
/* HttpClient */
28+
/* ------------------------------------------------------------------ */
29+
30+
export class HttpClient {
31+
private baseUrl: string;
32+
private defaultTimeout: number;
33+
private requestInterceptors: RequestInterceptor[] = [];
34+
private responseInterceptors: ResponseInterceptor[] = [];
35+
36+
constructor(baseUrl = '', timeout = 60_000) {
37+
this.baseUrl = baseUrl;
38+
this.defaultTimeout = timeout;
39+
}
40+
41+
/* ---------- interceptor registration ---------- */
42+
43+
onRequest(fn: RequestInterceptor): void {
44+
this.requestInterceptors.push(fn);
45+
}
46+
47+
onResponse(fn: ResponseInterceptor): void {
48+
this.responseInterceptors.push(fn);
49+
}
50+
51+
/* ---------- public request helpers ---------- */
52+
53+
async get<T = unknown>(path: string, opts: RequestOptions = {}): Promise<T> {
54+
const res = await this.request(path, { ...opts, method: 'GET' });
55+
return res.json() as Promise<T>;
56+
}
57+
58+
async post<T = unknown>(path: string, body?: unknown, opts: RequestOptions = {}): Promise<T> {
59+
const res = await this.request(path, {
60+
...opts,
61+
method: 'POST',
62+
body: body != null ? JSON.stringify(body) : undefined,
63+
headers: {
64+
...(body != null ? { 'Content-Type': 'application/json' } : {}),
65+
...opts.headers,
66+
},
67+
});
68+
return res.json() as Promise<T>;
69+
}
70+
71+
async put<T = unknown>(path: string, body?: unknown, opts: RequestOptions = {}): Promise<T> {
72+
const res = await this.request(path, {
73+
...opts,
74+
method: 'PUT',
75+
body: body != null ? JSON.stringify(body) : undefined,
76+
headers: {
77+
...(body != null ? { 'Content-Type': 'application/json' } : {}),
78+
...opts.headers,
79+
},
80+
});
81+
return res.json() as Promise<T>;
82+
}
83+
84+
async delete<T = unknown>(path: string, opts: RequestOptions = {}): Promise<T> {
85+
const res = await this.request(path, { ...opts, method: 'DELETE' });
86+
return res.json() as Promise<T>;
87+
}
88+
89+
/**
90+
* Low-level request that returns the raw `Response`.
91+
* Useful for streaming (SSE) endpoints where the caller needs `response.body`.
92+
*/
93+
async raw(path: string, opts: RequestOptions & { method?: string; body?: BodyInit | null } = {}): Promise<Response> {
94+
return this.request(path, opts);
95+
}
96+
97+
/* ---------- internal plumbing ---------- */
98+
99+
private buildUrl(path: string, params?: Record<string, string | number | boolean | undefined>): string {
100+
const url = `${this.baseUrl}${path}`;
101+
if (!params) return url;
102+
103+
const qs = new URLSearchParams();
104+
for (const [key, value] of Object.entries(params)) {
105+
if (value !== undefined) {
106+
qs.set(key, String(value));
107+
}
108+
}
109+
const queryString = qs.toString();
110+
return queryString ? `${url}?${queryString}` : url;
111+
}
112+
113+
private async request(path: string, opts: RequestOptions & { method?: string; body?: BodyInit | null } = {}): Promise<Response> {
114+
const { params, timeout, ...fetchOpts } = opts;
115+
const url = this.buildUrl(path, params);
116+
const effectiveTimeout = timeout ?? this.defaultTimeout;
117+
118+
// Build the init object
119+
let init: RequestInit = { ...fetchOpts };
120+
121+
// Run request interceptors
122+
for (const interceptor of this.requestInterceptors) {
123+
init = await interceptor(url, init);
124+
}
125+
126+
// Timeout via AbortController (merged with caller-supplied signal)
127+
const timeoutCtrl = new AbortController();
128+
const callerSignal = init.signal;
129+
130+
// If caller already passed a signal, listen for its abort
131+
if (callerSignal) {
132+
if (callerSignal.aborted) {
133+
timeoutCtrl.abort(callerSignal.reason);
134+
} else {
135+
callerSignal.addEventListener('abort', () => timeoutCtrl.abort(callerSignal.reason), { once: true });
136+
}
137+
}
138+
139+
const timer = effectiveTimeout > 0
140+
? setTimeout(() => timeoutCtrl.abort(new DOMException('Request timed out', 'TimeoutError')), effectiveTimeout)
141+
: undefined;
142+
143+
init.signal = timeoutCtrl.signal;
144+
145+
try {
146+
let response = await fetch(url, init);
147+
148+
// Run response interceptors
149+
for (const interceptor of this.responseInterceptors) {
150+
response = await interceptor(response);
151+
}
152+
153+
return response;
154+
} finally {
155+
if (timer !== undefined) clearTimeout(timer);
156+
}
157+
}
158+
}
159+
160+
/* ------------------------------------------------------------------ */
161+
/* Singleton instance with default interceptors */
162+
/* ------------------------------------------------------------------ */
163+
164+
const httpClient = new HttpClient('/api');
165+
166+
// ---- request interceptor: auth headers ----
167+
httpClient.onRequest((_url, init) => {
168+
const headers = new Headers(init.headers);
169+
170+
// Attach userId from Redux store (lazy import to avoid circular deps).
171+
// Falls back to 'anonymous' if store isn't ready yet.
172+
try {
173+
// eslint-disable-next-line @typescript-eslint/no-require-imports
174+
const { store } = require('../store/store');
175+
const userId: string = store.getState().app.userId || 'anonymous';
176+
headers.set('X-Ms-Client-Principal-Id', userId);
177+
} catch {
178+
headers.set('X-Ms-Client-Principal-Id', 'anonymous');
179+
}
180+
181+
return { ...init, headers };
182+
});
183+
184+
// ---- response interceptor: uniform error handling ----
185+
httpClient.onResponse((response) => {
186+
if (!response.ok) {
187+
// Don't throw for streaming endpoints — callers handle those manually.
188+
// Clone so the body remains readable for callers that want custom handling.
189+
const cloned = response.clone();
190+
console.error(
191+
`[httpClient] ${response.status} ${response.statusText}${cloned.url}`,
192+
);
193+
}
194+
return response;
195+
});
196+
197+
export default httpClient;

content-gen/src/app/frontend/src/api/index.ts

Lines changed: 33 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,13 @@ import type {
99
ParsedBriefResponse,
1010
AppConfig,
1111
} from '../types';
12-
13-
const API_BASE = '/api';
12+
import httpClient from './httpClient';
1413

1514
/**
1615
* Get application configuration including feature flags
1716
*/
1817
export async function getAppConfig(): Promise<AppConfig> {
19-
const response = await fetch(`${API_BASE}/config`);
20-
21-
if (!response.ok) {
22-
throw new Error(`Failed to get config: ${response.statusText}`);
23-
}
24-
25-
return response.json();
18+
return httpClient.get<AppConfig>('/config');
2619
}
2720

2821
/**
@@ -34,22 +27,11 @@ export async function parseBrief(
3427
userId?: string,
3528
signal?: AbortSignal
3629
): Promise<ParsedBriefResponse> {
37-
const response = await fetch(`${API_BASE}/brief/parse`, {
38-
signal,
39-
method: 'POST',
40-
headers: { 'Content-Type': 'application/json' },
41-
body: JSON.stringify({
42-
brief_text: briefText,
43-
conversation_id: conversationId,
44-
user_id: userId || 'anonymous',
45-
}),
46-
});
47-
48-
if (!response.ok) {
49-
throw new Error(`Failed to parse brief: ${response.statusText}`);
50-
}
51-
52-
return response.json();
30+
return httpClient.post<ParsedBriefResponse>('/brief/parse', {
31+
brief_text: briefText,
32+
conversation_id: conversationId,
33+
user_id: userId || 'anonymous',
34+
}, { signal });
5335
}
5436

5537
/**
@@ -60,21 +42,11 @@ export async function confirmBrief(
6042
conversationId?: string,
6143
userId?: string
6244
): Promise<{ status: string; conversation_id: string; brief: CreativeBrief }> {
63-
const response = await fetch(`${API_BASE}/brief/confirm`, {
64-
method: 'POST',
65-
headers: { 'Content-Type': 'application/json' },
66-
body: JSON.stringify({
67-
brief,
68-
conversation_id: conversationId,
69-
user_id: userId || 'anonymous',
70-
}),
45+
return httpClient.post('/brief/confirm', {
46+
brief,
47+
conversation_id: conversationId,
48+
user_id: userId || 'anonymous',
7149
});
72-
73-
if (!response.ok) {
74-
throw new Error(`Failed to confirm brief: ${response.statusText}`);
75-
}
76-
77-
return response.json();
7850
}
7951

8052
/**
@@ -87,23 +59,12 @@ export async function selectProducts(
8759
userId?: string,
8860
signal?: AbortSignal
8961
): Promise<{ products: Product[]; action: string; message: string; conversation_id: string }> {
90-
const response = await fetch(`${API_BASE}/products/select`, {
91-
signal,
92-
method: 'POST',
93-
headers: { 'Content-Type': 'application/json' },
94-
body: JSON.stringify({
95-
request,
96-
current_products: currentProducts,
97-
conversation_id: conversationId,
98-
user_id: userId || 'anonymous',
99-
}),
100-
});
101-
102-
if (!response.ok) {
103-
throw new Error(`Failed to select products: ${response.statusText}`);
104-
}
105-
106-
return response.json();
62+
return httpClient.post('/products/select', {
63+
request,
64+
current_products: currentProducts,
65+
conversation_id: conversationId,
66+
user_id: userId || 'anonymous',
67+
}, { signal });
10768
}
10869

10970
/**
@@ -115,9 +76,9 @@ export async function* streamChat(
11576
userId?: string,
11677
signal?: AbortSignal
11778
): AsyncGenerator<AgentResponse> {
118-
const response = await fetch(`${API_BASE}/chat`, {
119-
signal,
79+
const response = await httpClient.raw('/chat', {
12080
method: 'POST',
81+
signal,
12182
headers: { 'Content-Type': 'application/json' },
12283
body: JSON.stringify({
12384
message,
@@ -174,27 +135,16 @@ export async function* streamGenerateContent(
174135
signal?: AbortSignal
175136
): AsyncGenerator<AgentResponse> {
176137
// Use polling-based approach for reliability with long-running tasks
177-
const startResponse = await fetch(`${API_BASE}/generate/start`, {
178-
signal,
179-
method: 'POST',
180-
headers: { 'Content-Type': 'application/json' },
181-
body: JSON.stringify({
182-
brief,
183-
products: products || [],
184-
generate_images: generateImages,
185-
conversation_id: conversationId,
186-
user_id: userId || 'anonymous',
187-
}),
188-
});
189-
190-
if (!startResponse.ok) {
191-
throw new Error(`Content generation failed to start: ${startResponse.statusText}`);
192-
}
193-
194-
const startData = await startResponse.json();
138+
const startData = await httpClient.post<{ task_id: string }>('/generate/start', {
139+
brief,
140+
products: products || [],
141+
generate_images: generateImages,
142+
conversation_id: conversationId,
143+
user_id: userId || 'anonymous',
144+
}, { signal });
195145
const taskId = startData.task_id;
196146

197-
console.log(`Generation started with task ID: ${taskId}`);
147+
console.debug(`Generation started with task ID: ${taskId}`);
198148

199149
// Yield initial status
200150
yield {
@@ -223,12 +173,10 @@ export async function* streamGenerateContent(
223173
}
224174

225175
try {
226-
const statusResponse = await fetch(`${API_BASE}/generate/status/${taskId}`, { signal });
227-
if (!statusResponse.ok) {
228-
throw new Error(`Failed to get task status: ${statusResponse.statusText}`);
229-
}
230-
231-
const statusData = await statusResponse.json();
176+
const statusData = await httpClient.get<{ status: string; result?: unknown; error?: string }>(
177+
`/generate/status/${taskId}`,
178+
{ signal },
179+
);
232180

233181
if (statusData.status === 'completed') {
234182
// Yield the final result
@@ -300,9 +248,9 @@ export async function* streamRegenerateImage(
300248
userId?: string,
301249
signal?: AbortSignal
302250
): AsyncGenerator<AgentResponse> {
303-
const response = await fetch(`${API_BASE}/regenerate`, {
304-
signal,
251+
const response = await httpClient.raw('/regenerate', {
305252
method: 'POST',
253+
signal,
306254
headers: { 'Content-Type': 'application/json' },
307255
body: JSON.stringify({
308256
modification_request: modificationRequest,

0 commit comments

Comments
 (0)