v4.0.0: 회원·덱 API, 인증 UI, 프로필 및 레이아웃 통일
Express/SQLite API와 Docker 구성을 추가하고 로그인·덱 저장·공개 덱·관리자·프로필 화면을 구현했다. 결과 화면 저장 UX, 헤더 프로필, 애드센스용 app-shell 레이아웃(1280px) 및 문서를 반영했다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
193
script/api.js
Normal file
193
script/api.js
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* UADECK API 클라이언트
|
||||
*/
|
||||
const UaDeckApi = (() => {
|
||||
const TOKEN_KEY = 'uadeck_token';
|
||||
const USER_KEY = 'uadeck_user';
|
||||
|
||||
/**
|
||||
* API 베이스 URL
|
||||
* @returns {string}
|
||||
*/
|
||||
function getBaseUrl() {
|
||||
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)로 직접 연결
|
||||
if (isLocal && port && port !== '8080' && port !== '80' && port !== '443' && 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 === 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<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 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'),
|
||||
|
||||
/** 관리자: 회원 목록 */
|
||||
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'),
|
||||
|
||||
/** @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;
|
||||
Reference in New Issue
Block a user