feat(api): 세션·통계·설정 API 연동 기반을 추가
맥락: - 실제 세션 엔진과 통계·설정 저장을 백엔드와 연결할 프론트 API 경계를 먼저 정리할 필요가 있었다. 변경사항: - focus session, stats, preferences API 계층과 타입을 추가하고 메서드 주석에 Backend Codex 지시 사항을 작성했다. - /space를 현재 세션 조회, 시작, 일시정지, 재개, 다시 시작, 완료, 종료 API 흐름에 연결하고 API 실패 시 로컬 미리보기 fallback을 유지했다. - /stats와 /settings를 API 기반 fetch/save 구조로 전환하고 auth/apiClient를 보강했다. - React 19 규칙에 맞게 관련 훅과 HUD/시트 구현을 정리해 lint/build가 통과하도록 보정했다. 검증: - npm run lint - npm run build 세션-상태: 프론트에서 세션·통계·설정 API를 호출할 준비가 된 상태 세션-다음: 백엔드가 주석에 맞춘 엔드포인트와 응답 스키마를 구현하도록 협업 세션-리스크: 실제 서버 응답 필드명이 현재 타입과 다르면 프론트 매핑 조정이 추가로 필요
This commit is contained in:
@@ -6,45 +6,107 @@
|
||||
* - 빌드 후 운영 환경: https://api.viberoom.io (from .env.production)
|
||||
*/
|
||||
|
||||
import Cookies from "js-cookie";
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8080";
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8080';
|
||||
const TOKEN_COOKIE_KEY = 'vr_access_token';
|
||||
|
||||
const TOKEN_COOKIE_KEY = "vr_access_token";
|
||||
interface ApiEnvelope<T> {
|
||||
data: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ApiErrorPayload {
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class ApiClientError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = 'ApiClientError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
interface ApiClientOptions extends RequestInit {
|
||||
expectNoContent?: boolean;
|
||||
unwrapData?: boolean;
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => {
|
||||
return typeof value === 'object' && value !== null;
|
||||
};
|
||||
|
||||
const readErrorMessage = async (response: Response) => {
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
|
||||
if (!contentType.includes('application/json')) {
|
||||
return `API 요청 실패: ${response.status}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = (await response.json()) as ApiErrorPayload;
|
||||
return payload.message ?? payload.error ?? `API 요청 실패: ${response.status}`;
|
||||
} catch {
|
||||
return `API 요청 실패: ${response.status}`;
|
||||
}
|
||||
};
|
||||
|
||||
export const apiClient = async <T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {},
|
||||
options: ApiClientOptions = {},
|
||||
): Promise<T> => {
|
||||
const url = `${API_BASE_URL}${endpoint.startsWith("/") ? endpoint : `/${endpoint}`}`;
|
||||
|
||||
// 쿠키에서 토큰 가져오기
|
||||
const {
|
||||
expectNoContent = false,
|
||||
unwrapData = true,
|
||||
headers,
|
||||
...requestOptions
|
||||
} = options;
|
||||
const url = `${API_BASE_URL}${endpoint.startsWith('/') ? endpoint : `/${endpoint}`}`;
|
||||
const token = Cookies.get(TOKEN_COOKIE_KEY);
|
||||
|
||||
const defaultHeaders: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
// 토큰이 있으면 Authorization 헤더 추가
|
||||
if (token) {
|
||||
defaultHeaders["Authorization"] = `Bearer ${token}`;
|
||||
defaultHeaders.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
...requestOptions,
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
...options.headers,
|
||||
...headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// 향후 서비스 무드에 맞춰 "잠시 연결이 원활하지 않아요. 다시 시도해 주세요." 와 같이
|
||||
// 부드러운 에러 핸들링을 추가할 수 있는 진입점입니다.
|
||||
throw new Error(`API 요청 실패: ${response.status}`);
|
||||
throw new ApiClientError(await readErrorMessage(response), response.status);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result.data as T;
|
||||
if (expectNoContent || response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
|
||||
if (!contentType.includes('application/json')) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
const result = (await response.json()) as T | ApiEnvelope<T>;
|
||||
|
||||
if (!unwrapData) {
|
||||
return result as T;
|
||||
}
|
||||
|
||||
if (isRecord(result) && 'data' in result) {
|
||||
return result.data as T;
|
||||
}
|
||||
|
||||
return result as T;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
export type HudStatusLinePriority = 'normal' | 'undo';
|
||||
|
||||
@@ -28,21 +28,14 @@ const DEFAULT_UNDO_DURATION_MS = 4200;
|
||||
|
||||
export const useHudStatusLine = (enabled = true) => {
|
||||
const [queue, setQueue] = useState<HudStatusLineItem[]>([]);
|
||||
const visibleQueue = useMemo(() => (enabled ? queue : []), [enabled, queue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
if (visibleQueue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setQueue([]);
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || queue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const active = queue[0];
|
||||
const active = visibleQueue[0];
|
||||
const durationMs =
|
||||
active.durationMs ?? (active.action ? DEFAULT_UNDO_DURATION_MS : DEFAULT_DURATION_MS);
|
||||
const timerId = window.setTimeout(() => {
|
||||
@@ -52,9 +45,13 @@ export const useHudStatusLine = (enabled = true) => {
|
||||
return () => {
|
||||
window.clearTimeout(timerId);
|
||||
};
|
||||
}, [enabled, queue]);
|
||||
}, [visibleQueue]);
|
||||
|
||||
const pushStatusLine = (payload: HudStatusLinePayload) => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pushStatusLine = useCallback((payload: HudStatusLinePayload) => {
|
||||
const nextItem: HudStatusLineItem = {
|
||||
id: Date.now() + Math.floor(Math.random() * 10000),
|
||||
...payload,
|
||||
@@ -74,14 +71,14 @@ export const useHudStatusLine = (enabled = true) => {
|
||||
|
||||
return nextQueue.slice(0, MAX_TOTAL_MESSAGES);
|
||||
});
|
||||
}, []);
|
||||
};
|
||||
|
||||
const dismissActiveStatus = useCallback(() => {
|
||||
const dismissActiveStatus = () => {
|
||||
setQueue((current) => current.slice(1));
|
||||
}, []);
|
||||
};
|
||||
|
||||
const runActiveAction = useCallback(() => {
|
||||
const active = queue[0];
|
||||
const runActiveAction = () => {
|
||||
const active = visibleQueue[0];
|
||||
|
||||
if (!active?.action) {
|
||||
dismissActiveStatus();
|
||||
@@ -90,14 +87,12 @@ export const useHudStatusLine = (enabled = true) => {
|
||||
|
||||
active.action.onClick();
|
||||
dismissActiveStatus();
|
||||
}, [dismissActiveStatus, queue]);
|
||||
};
|
||||
|
||||
return useMemo(() => {
|
||||
return {
|
||||
activeStatus: queue[0] ?? null,
|
||||
pushStatusLine,
|
||||
runActiveAction,
|
||||
dismissActiveStatus,
|
||||
};
|
||||
}, [dismissActiveStatus, pushStatusLine, queue, runActiveAction]);
|
||||
return {
|
||||
activeStatus: visibleQueue[0] ?? null,
|
||||
pushStatusLine,
|
||||
runActiveAction,
|
||||
dismissActiveStatus,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -20,7 +20,9 @@ interface ToastContextValue {
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
export const ToastProvider = ({ children }: { children: ReactNode }) => {
|
||||
const pushToast = useCallback((_payload: ToastPayload) => {}, []);
|
||||
const pushToast = useCallback((payload: ToastPayload) => {
|
||||
void payload;
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ pushToast }), [pushToast]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user