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:
2026-06-22 16:59:36 +09:00
parent f8c954f24b
commit dbf02241e2
56 changed files with 6732 additions and 183 deletions

193
script/api.js Normal file
View 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;

207
script/auth-header.js Normal file
View File

@@ -0,0 +1,207 @@
/**
* 헤더 프로필 드롭다운
*/
(function initAuthHeader() {
const DEFAULT_AVATAR =
'data:image/svg+xml,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#94a3b8"><path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"/></svg>',
);
let currentUser = null;
let dropdownOpen = false;
/**
* HTML 이스케이프
* @param {string} str
* @returns {string}
*/
function escapeHtml(str) {
return String(str ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/**
* 드롭다운 닫기
*/
function closeDropdown() {
const dd = document.getElementById('auth-dropdown');
if (dd) dd.classList.remove('open');
dropdownOpen = false;
}
/**
* 아바타 이미지 src (캐시 무효화 포함)
* @param {object|null} user
* @returns {string}
*/
function resolveAvatarSrc(user) {
const raw = user?.avatarUrl;
if (!raw) return DEFAULT_AVATAR;
let src =
window.UaDeckApi?.resolveMediaUrl
? window.UaDeckApi.resolveMediaUrl(raw) || raw
: raw;
if (user?.updatedAt && src && !src.startsWith('data:')) {
src += (src.includes('?') ? '&' : '?') + 't=' + encodeURIComponent(user.updatedAt);
}
return src;
}
/**
* 아바타 HTML
* @param {object|null} user
* @returns {string}
*/
function avatarHtml(user) {
const src = resolveAvatarSrc(user);
return `<img src="${src}" alt="profile" onerror="this.src='${DEFAULT_AVATAR}'" />`;
}
/**
* 프로필 버튼 HTML (아바타 + 사용자명)
* @param {object|null} user
* @returns {string}
*/
function profileBtnHtml(user) {
const avatar = avatarHtml(user);
if (!user?.username) {
return `<span class="auth-avatar-wrap">${avatar}</span>`;
}
return `
<span class="auth-avatar-wrap">${avatar}</span>
<span class="auth-username">${escapeHtml(user.username)}</span>
`;
}
/**
* 드롭다운 메뉴 렌더
*/
function renderDropdown() {
const dd = document.getElementById('auth-dropdown');
if (!dd) return;
if (!currentUser) {
dd.innerHTML = `
<a href="/login.html" class="auth-dropdown-item">로그인</a>
<a href="/signup.html" class="auth-dropdown-item">회원가입</a>
`;
return;
}
const adminLink =
currentUser.role === 'admin'
? `<a href="/admin.html" class="auth-dropdown-item">관리자</a>`
: '';
dd.innerHTML = `
<div class="auth-dropdown-header">
<div class="auth-dropdown-name">${currentUser.username}</div>
<div class="auth-dropdown-email">${currentUser.email || ''}</div>
</div>
<a href="/profile.html" class="auth-dropdown-item">내 프로필</a>
<a href="/deckHistory.html" class="auth-dropdown-item">내 덱 관리</a>
<a href="/preset.html" class="auth-dropdown-item">공개 덱</a>
${adminLink}
<div class="auth-dropdown-divider"></div>
<button type="button" class="auth-dropdown-item danger" id="auth-logout-btn">로그아웃</button>
`;
const logoutBtn = document.getElementById('auth-logout-btn');
if (logoutBtn) {
logoutBtn.addEventListener('click', () => {
window.UaDeckApi.logout();
currentUser = null;
closeDropdown();
location.href = '/';
});
}
}
/**
* 헤더 UI 갱신
*/
function renderHeader() {
const btn = document.getElementById('auth-avatar-btn');
if (btn) {
btn.innerHTML = profileBtnHtml(currentUser);
btn.classList.toggle('auth-profile-btn--named', Boolean(currentUser?.username));
}
renderDropdown();
}
/**
* 슬롯에 헤더 마운트
* @param {HTMLElement} slot
*/
function mount(slot) {
slot.innerHTML = `
<div class="auth-header-wrap">
<button type="button" id="auth-avatar-btn" class="auth-avatar-btn" aria-label="계정 메뉴">
${profileBtnHtml(currentUser)}
</button>
<div id="auth-dropdown" class="auth-dropdown"></div>
</div>
`;
document.getElementById('auth-avatar-btn').addEventListener('click', async (e) => {
e.stopPropagation();
dropdownOpen = !dropdownOpen;
const dd = document.getElementById('auth-dropdown');
if (dropdownOpen) {
await refreshCurrentUser();
renderHeader();
dd.classList.add('open');
} else {
dd.classList.remove('open');
}
});
document.addEventListener('click', closeDropdown);
}
/**
* 서버에서 최신 사용자 정보 동기화
*/
async function refreshCurrentUser() {
if (!window.UaDeckApi?.getToken()) {
currentUser = null;
return;
}
try {
const { user } = await window.UaDeckApi.getMe();
currentUser = user;
window.UaDeckApi.saveAuth(window.UaDeckApi.getToken(), user);
} catch {
window.UaDeckApi.logout();
currentUser = null;
}
}
/**
* 초기화
*/
async function init() {
currentUser = window.UaDeckApi.getCachedUser();
await refreshCurrentUser();
const slot = document.getElementById('auth-header-slot');
if (slot) {
mount(slot);
renderHeader();
} else {
renderHeader();
}
}
document.addEventListener('DOMContentLoaded', init);
window.UaDeckAuth = {
getUser: () => currentUser,
refresh: init,
refreshCurrentUser,
};
})();

55
script/deck-prefs.js Normal file
View File

@@ -0,0 +1,55 @@
/**
* 덱 저장 시 공개 여부 기본값·프롬프트 설정
*/
const UaDeckPrefs = (() => {
const LS_DEFAULT_PUBLIC = 'uadeck_default_deck_public';
const LS_SKIP_PROMPT = 'uadeck_skip_deck_public_prompt';
/**
* @param {object|null|undefined} user
* @returns {{ defaultPublic: boolean, skipPrompt: boolean }}
*/
function getPrefs(user) {
if (user && (user.defaultDeckPublic !== undefined || user.skipDeckPublicPrompt !== undefined)) {
return {
defaultPublic: user.defaultDeckPublic !== false,
skipPrompt: Boolean(user.skipDeckPublicPrompt),
};
}
return {
defaultPublic: localStorage.getItem(LS_DEFAULT_PUBLIC) !== '0',
skipPrompt: localStorage.getItem(LS_SKIP_PROMPT) === '1',
};
}
/**
* 로컬 캐시 동기화
* @param {{ defaultPublic: boolean, skipPrompt: boolean }} prefs
*/
function syncLocal(prefs) {
localStorage.setItem(LS_DEFAULT_PUBLIC, prefs.defaultPublic ? '1' : '0');
localStorage.setItem(LS_SKIP_PROMPT, prefs.skipPrompt ? '1' : '0');
}
/**
* @param {{ defaultPublic: boolean, skipPrompt: boolean }} prefs
* @returns {Promise<void>}
*/
async function savePrefs(prefs) {
syncLocal(prefs);
if (!window.UaDeckApi?.getToken()) return;
try {
const { user } = await window.UaDeckApi.updateProfile({
defaultDeckPublic: prefs.defaultPublic,
skipDeckPublicPrompt: prefs.skipPrompt,
});
window.UaDeckApi.saveAuth(window.UaDeckApi.getToken(), user);
} catch (err) {
console.warn('덱 저장 설정 동기화 실패:', err);
}
}
return { getPrefs, syncLocal, savePrefs };
})();
window.UaDeckPrefs = UaDeckPrefs;

35
script/image-utils.js Normal file
View File

@@ -0,0 +1,35 @@
/**
* 이미지 파일을 PNG data URL로 리사이즈
* @param {File} file
* @param {number} [maxSize=320]
* @returns {Promise<string>}
*/
function resizeImageToPng(file, maxSize = 320) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('이미지를 읽을 수 없습니다.'));
reader.onload = () => {
const img = new Image();
img.onerror = () => reject(new Error('이미지 형식이 올바르지 않습니다.'));
img.onload = () => {
const scale = Math.min(maxSize / img.width, maxSize / img.height, 1);
const width = Math.max(1, Math.round(img.width * scale));
const height = Math.max(1, Math.round(img.height * scale));
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('이미지 처리에 실패했습니다.'));
return;
}
ctx.drawImage(img, 0, 0, width, height);
resolve(canvas.toDataURL('image/png'));
};
img.src = reader.result;
};
reader.readAsDataURL(file);
});
}
window.UaImageUtils = { resizeImageToPng };

View File

@@ -66,22 +66,21 @@ function goToCardSeries() {
}
function goToDeckHistory() {
window.location.href = "deckHistory.html"; // 덱 기록 페이지로 이동
window.location.href = "deckHistory.html";
toggleMobileMenu();
}
function goToChangeLanguage() {
// 언어 변경 페이지로 이동 (페이지가 없으면 기능 구현 필요)
toggleMobileMenu();
}
function goToPreset() {
window.location.href = "preset.html"; // 메타 덱 페이지로 이동
window.location.href = "preset.html";
toggleMobileMenu();
}
function goToMyCardsDatabase() {
window.location.href = "myDatabase.html"; // 내 카드 데이터베이스 페이지로 이동
window.location.href = "deckHistory.html";
toggleMobileMenu();
}