Files

299 lines
9.9 KiB
JavaScript

/**
* 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})`;
}
/**
* API 오류 객체 생성
* @param {Response} res
* @param {object} data
* @returns {Error & {status?: number, code?: string, data?: object}}
*/
function createApiError(res, data) {
const error = new Error(formatError(res, data));
error.status = res.status;
error.code = data.code;
error.data = data;
return error;
}
/**
* 저장된 토큰
* @returns {string|null}
*/
function getToken() {
return localStorage.getItem(TOKEN_KEY) || sessionStorage.getItem(TOKEN_KEY);
}
/**
* 캐시된 사용자
* @returns {object|null}
*/
function getCachedUser() {
try {
const storage = localStorage.getItem(TOKEN_KEY) ? localStorage : sessionStorage;
const raw = storage.getItem(USER_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
/**
* 인증 정보 저장
* @param {string} token
* @param {object} user
* @param {boolean|null} [remember=null] 로그인 유지 여부. null이면 현재 저장 위치를 유지한다.
*/
function saveAuth(token, user, remember = null) {
const shouldRemember =
remember === null ? Boolean(localStorage.getItem(TOKEN_KEY)) || !sessionStorage.getItem(TOKEN_KEY) : remember;
const storage = shouldRemember ? localStorage : sessionStorage;
const otherStorage = shouldRemember ? sessionStorage : localStorage;
storage.setItem(TOKEN_KEY, token);
storage.setItem(USER_KEY, JSON.stringify(user));
otherStorage.removeItem(TOKEN_KEY);
otherStorage.removeItem(USER_KEY);
}
/**
* 로그아웃
*/
function logout() {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
sessionStorage.removeItem(TOKEN_KEY);
sessionStorage.removeItem(USER_KEY);
}
/**
* API 요청
* @param {string} path
* @param {object} [options]
* @returns {Promise<object>}
*/
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 createApiError(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) }),
/** @param {string} email */
resendVerification: (email) =>
request('/auth/resend-verification', {
method: 'POST',
body: JSON.stringify({ email }),
}),
/** @param {string} token */
verifyEmail: (token) =>
request('/auth/verify-email', {
method: 'POST',
body: JSON.stringify({ token }),
}),
/** @param {string} email */
forgotPassword: (email) =>
request('/auth/forgot-password', {
method: 'POST',
body: JSON.stringify({ email }),
}),
/** @param {string} token @param {string} newPassword */
resetPassword: (token, newPassword) =>
request('/auth/reset-password', {
method: 'POST',
body: JSON.stringify({ token, newPassword }),
}),
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} confirmation */
deleteMyAccount: (confirmation) =>
request('/auth/me', {
method: 'DELETE',
body: JSON.stringify({ confirmation }),
}),
/** @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 */
adminVerifyUserEmail: (id) =>
request(`/admin/users/${id}/verify-email`, { method: 'POST' }),
/** @param {number} id @param {string} newPassword */
adminResetPassword: (id, newPassword) =>
request(`/admin/users/${id}/reset-password`, {
method: 'POST',
body: JSON.stringify({ newPassword }),
}),
/** @param {number} id @param {string} confirmation */
adminDeleteUser: (id, confirmation) =>
request(`/admin/users/${id}`, {
method: 'DELETE',
body: JSON.stringify({ confirmation }),
}),
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[]} shareCodes */
adminDeleteDecks: (shareCodes) =>
request('/admin/decks/bulk-delete', {
method: 'POST',
body: JSON.stringify({ shareCodes }),
}),
/** @param {string} shareCode */
adminDeleteDeck: (shareCode) =>
request(`/admin/decks/${shareCode}`, { method: 'DELETE' }),
};
})();
window.UaDeckApi = UaDeckApi;