/** * UNIARE API 클라이언트 */ const UaDeckApi = (() => { const TOKEN_KEY = 'uadeck_token'; const USER_KEY = 'uadeck_user'; /** * API 베이스 URL * @returns {string} */ function getBaseUrl() { if (window.UNIARE_API_BASE) return window.UNIARE_API_BASE; if (window.UADECK_API_BASE) return window.UADECK_API_BASE; const { hostname, port, protocol } = window.location; const isLocal = hostname === 'localhost' || hostname === '127.0.0.1'; // Live Server 등 정적 전용 포트 → API 컨테이너/프로세스(3000)로 직접 연결 const proxiedWebPorts = new Set(['44082', '8080', '80', '443', '']); if (isLocal && port && !proxiedWebPorts.has(port)) { return `${protocol}//${hostname}:3000/api/v1`; } return '/api/v1'; } /** * API 미디어 경로를 브라우저에서 로드 가능한 URL로 변환 * (Live Server 등 API 호스트가 다른 경우 대비) * @param {string|null} apiPath * @returns {string|null} */ function resolveMediaUrl(apiPath) { if (!apiPath) return null; if (apiPath.startsWith('http://') || apiPath.startsWith('https://')) return apiPath; const base = getBaseUrl(); if (base.startsWith('http')) { const origin = base.replace(/\/api\/v1\/?$/, ''); return `${origin}${apiPath}`; } return apiPath; } /** * HTTP 오류 메시지 * @param {Response} res * @param {object} data * @returns {string} */ function formatError(res, data) { if (res.status === 405 || res.status === 404) { return 'API 서버에 연결되지 않았습니다. Docker 또는 npm run api:dev 로 API를 실행하세요.'; } if (res.status === 413) { return data.error || '업로드 크기가 제한을 초과했습니다. 고화질 옵션을 끄고 다시 시도해 주세요.'; } if (res.status === 0 || res.type === 'opaque') { return '네트워크 오류입니다. API 서버가 실행 중인지 확인하세요.'; } return data.error || `요청 실패 (${res.status})`; } /** * 저장된 토큰 * @returns {string|null} */ function getToken() { return localStorage.getItem(TOKEN_KEY); } /** * 캐시된 사용자 * @returns {object|null} */ function getCachedUser() { try { const raw = localStorage.getItem(USER_KEY); return raw ? JSON.parse(raw) : null; } catch { return null; } } /** * 인증 정보 저장 * @param {string} token * @param {object} user */ function saveAuth(token, user) { localStorage.setItem(TOKEN_KEY, token); localStorage.setItem(USER_KEY, JSON.stringify(user)); } /** * 로그아웃 */ function logout() { localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(USER_KEY); } /** * API 요청 * @param {string} path * @param {object} [options] * @returns {Promise} */ async function request(path, options = {}) { const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) }; const token = getToken(); if (token) headers.Authorization = `Bearer ${token}`; const res = await fetch(`${getBaseUrl()}${path}`, { ...options, headers }); const data = await res.json().catch(() => ({})); if (!res.ok) { throw new Error(formatError(res, data)); } return data; } return { getToken, getCachedUser, saveAuth, logout, getBaseUrl, resolveMediaUrl, /** @param {string} email @param {string} password */ login: (email, password) => request('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }), /** @param {object} body */ register: (body) => request('/auth/register', { method: 'POST', body: JSON.stringify(body) }), getMe: () => request('/auth/me'), /** @param {object} body */ updateProfile: (body) => request('/auth/me', { method: 'PUT', body: JSON.stringify(body) }), /** @param {string} currentPassword @param {string} newPassword */ changePassword: (currentPassword, newPassword) => request('/auth/me/password', { method: 'POST', body: JSON.stringify({ currentPassword, newPassword }), }), /** @param {string} shareCode */ getDeck: (shareCode) => request(`/decks/${shareCode}`), getMyDecks: () => request('/decks'), /** @param {object} body */ saveDeck: (body) => request('/decks', { method: 'POST', body: JSON.stringify(body) }), /** @param {string} shareCode @param {object} body */ updateDeck: (shareCode, body) => request(`/decks/${shareCode}`, { method: 'PUT', body: JSON.stringify(body) }), /** @param {string} shareCode */ deleteDeck: (shareCode) => request(`/decks/${shareCode}`, { method: 'DELETE' }), getPresets: () => request('/decks/presets'), getFavorites: () => request('/decks/favorites'), /** @param {string[]} shareCodes */ syncFavorites: (shareCodes) => request('/decks/favorites', { method: 'POST', body: JSON.stringify({ shareCodes }), }), /** @param {string} shareCode */ addFavorite: (shareCode) => request(`/decks/favorites/${shareCode}`, { method: 'PUT' }), /** @param {string} shareCode */ removeFavorite: (shareCode) => request(`/decks/favorites/${shareCode}`, { method: 'DELETE' }), /** 관리자: 회원 목록 */ adminGetUsers: (q = '', page = 1) => request(`/admin/users?q=${encodeURIComponent(q)}&page=${page}`), /** @param {number} id */ adminGetUser: (id) => request(`/admin/users/${id}`), /** @param {number} id @param {object} body */ adminUpdateUser: (id, body) => request(`/admin/users/${id}`, { method: 'PUT', body: JSON.stringify(body) }), /** @param {number} id @param {string} newPassword */ adminResetPassword: (id, newPassword) => request(`/admin/users/${id}/reset-password`, { method: 'POST', body: JSON.stringify({ newPassword }), }), adminGetDecks: () => request('/admin/decks'), adminGetOrphanPreviews: () => request('/admin/previews/orphans'), adminDeleteOrphanPreviews: () => request('/admin/previews/orphans', { method: 'DELETE' }), /** @param {object} body */ adminCreatePreset: (body) => request('/admin/decks/preset', { method: 'POST', body: JSON.stringify(body) }), /** @param {string} shareCode */ adminDeleteDeck: (shareCode) => request(`/admin/decks/${shareCode}`, { method: 'DELETE' }), }; })(); window.UaDeckApi = UaDeckApi;