- Backend: FastAPI + MongoDB + Redis (카테고리, 할일 CRUD, 파일 첨부, 검색, 대시보드) - Frontend: Next.js 15 + Tailwind + React Query + Zustand - 통합 TodoModal: 생성/수정 모달 통합, 탭 구조 (기본/태그와 첨부) - 간트차트: 카테고리별 할일 타임라인 시각화 - TodoCard: 제목/카테고리/우선순위/태그/첨부 한줄 표시 - Docker Compose 배포 (Frontend:3010, Backend:8010, MongoDB:27021, Redis:6391) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
122 lines
3.0 KiB
TypeScript
122 lines
3.0 KiB
TypeScript
import { ApiError } from "@/types";
|
|
|
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
|
|
|
class ApiClient {
|
|
private baseUrl: string;
|
|
|
|
constructor(baseUrl: string) {
|
|
this.baseUrl = baseUrl;
|
|
}
|
|
|
|
private async request<T>(
|
|
method: string,
|
|
path: string,
|
|
options?: {
|
|
body?: unknown;
|
|
params?: Record<string, string | number | boolean | undefined | null>;
|
|
}
|
|
): Promise<T> {
|
|
const url = new URL(`${this.baseUrl}${path}`);
|
|
|
|
if (options?.params) {
|
|
Object.entries(options.params).forEach(([key, value]) => {
|
|
if (value !== undefined && value !== null) {
|
|
url.searchParams.set(key, String(value));
|
|
}
|
|
});
|
|
}
|
|
|
|
const res = await fetch(url.toString(), {
|
|
method,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: options?.body ? JSON.stringify(options.body) : undefined,
|
|
});
|
|
|
|
if (res.status === 204) {
|
|
return undefined as T;
|
|
}
|
|
|
|
if (!res.ok) {
|
|
const error = await res
|
|
.json()
|
|
.catch(() => ({ detail: "알 수 없는 오류가 발생했습니다" }));
|
|
throw {
|
|
detail: error.detail || `HTTP ${res.status} Error`,
|
|
status: res.status,
|
|
} as ApiError;
|
|
}
|
|
|
|
return res.json();
|
|
}
|
|
|
|
get<T>(
|
|
path: string,
|
|
params?: Record<string, string | number | boolean | undefined | null>
|
|
) {
|
|
return this.request<T>("GET", path, { params });
|
|
}
|
|
|
|
post<T>(path: string, body?: unknown) {
|
|
return this.request<T>("POST", path, { body });
|
|
}
|
|
|
|
put<T>(path: string, body?: unknown) {
|
|
return this.request<T>("PUT", path, { body });
|
|
}
|
|
|
|
patch<T>(path: string, body?: unknown) {
|
|
return this.request<T>("PATCH", path, { body });
|
|
}
|
|
|
|
delete<T>(path: string) {
|
|
return this.request<T>("DELETE", path);
|
|
}
|
|
|
|
async uploadFiles<T>(path: string, files: File[]): Promise<T> {
|
|
const url = new URL(`${this.baseUrl}${path}`);
|
|
const formData = new FormData();
|
|
files.forEach((file) => formData.append("files", file));
|
|
|
|
const res = await fetch(url.toString(), {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const error = await res
|
|
.json()
|
|
.catch(() => ({ detail: "파일 업로드에 실패했습니다" }));
|
|
throw {
|
|
detail: error.detail || `HTTP ${res.status} Error`,
|
|
status: res.status,
|
|
} as ApiError;
|
|
}
|
|
|
|
return res.json();
|
|
}
|
|
|
|
async downloadFile(path: string, filename: string): Promise<void> {
|
|
const url = new URL(`${this.baseUrl}${path}`);
|
|
const res = await fetch(url.toString());
|
|
|
|
if (!res.ok) {
|
|
throw new Error("다운로드에 실패했습니다");
|
|
}
|
|
|
|
const blob = await res.blob();
|
|
const downloadUrl = window.URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = downloadUrl;
|
|
link.download = filename;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
window.URL.revokeObjectURL(downloadUrl);
|
|
}
|
|
}
|
|
|
|
export const apiClient = new ApiClient(API_BASE_URL);
|