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

405
admin.html Normal file
View File

@@ -0,0 +1,405 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="./style/output.css" />
<link rel="stylesheet" href="./style/auth-header.css" />
<link rel="stylesheet" href="./style/admin.css" />
<title>관리자 - UADECK</title>
</head>
<body class="admin-page">
<nav class="admin-nav">
<div class="admin-nav-inner">
<a href="/" class="admin-brand">UADECK <span>v4.0.0</span> 관리자</a>
<div id="auth-header-slot"></div>
</div>
</nav>
<main class="admin-main">
<header class="admin-header">
<h1>관리자</h1>
<p>회원과 저장된 덱을 관리합니다.</p>
</header>
<div class="admin-tabs">
<button type="button" data-tab="users" class="admin-tab active">회원 관리</button>
<button type="button" data-tab="decks" class="admin-tab">덱 관리</button>
</div>
<section id="tab-users">
<div class="admin-toolbar">
<input type="search" id="user-search" class="admin-search" placeholder="이메일, 사용자명 검색..." />
<button type="button" id="user-search-btn" class="admin-btn admin-btn-primary">검색</button>
</div>
<div id="user-list" class="admin-user-list"></div>
</section>
<section id="tab-decks" class="hidden">
<p id="deck-count" class="admin-count"></p>
<div id="admin-deck-list" class="admin-deck-grid"></div>
</section>
</main>
<!-- 회원 정보 모달 -->
<div id="edit-modal" class="admin-modal">
<div class="admin-modal-box">
<div class="admin-modal-header">
<div class="admin-modal-profile">
<div class="admin-modal-avatar-wrap">
<img id="edit-avatar-preview" class="admin-modal-avatar" alt="" />
<button type="button" id="edit-avatar-btn" class="admin-modal-avatar-btn" title="사진 변경">📷</button>
<input type="file" id="edit-avatar-file" accept="image/*" class="hidden" />
</div>
<div>
<h2 id="edit-modal-title" class="admin-modal-title">회원 정보</h2>
<p id="edit-modal-sub" class="admin-modal-sub"></p>
</div>
</div>
<button type="button" id="edit-avatar-remove" class="admin-modal-avatar-remove hidden">프로필 사진 제거</button>
</div>
<div class="admin-modal-content">
<input type="hidden" id="edit-user-id" />
<div class="admin-field">
<label class="admin-label" for="edit-email">이메일</label>
<input type="email" id="edit-email" class="admin-input" autocomplete="off" />
</div>
<div class="admin-field">
<label class="admin-label" for="edit-username">사용자명</label>
<input type="text" id="edit-username" class="admin-input" autocomplete="off" />
</div>
<div class="admin-field">
<label class="admin-label" for="edit-role">역할</label>
<select id="edit-role" class="admin-select">
<option value="user">일반 회원</option>
<option value="admin">관리자</option>
</select>
<p id="edit-role-hint" class="admin-field-hint warn hidden">본인 계정의 관리자 권한은 변경할 수 없습니다.</p>
</div>
<div class="admin-field">
<label class="admin-label" for="edit-isActive">상태</label>
<select id="edit-isActive" class="admin-select">
<option value="1">활성</option>
<option value="0">비활성</option>
</select>
<p id="edit-status-hint" class="admin-field-hint warn hidden">본인 계정은 비활성화할 수 없습니다.</p>
</div>
<p id="modal-error" class="admin-error hidden"></p>
<div class="admin-modal-divider"></div>
<button type="button" id="open-password-modal" class="admin-btn admin-btn-outline" style="width:100%">비밀번호 변경하기</button>
<div class="admin-modal-actions-row" style="margin-top:0.75rem">
<button type="button" id="modal-cancel" class="admin-btn admin-btn-secondary">취소</button>
<button type="button" id="modal-save" class="admin-btn admin-btn-primary">저장</button>
</div>
</div>
</div>
</div>
<!-- 비밀번호 변경 모달 -->
<div id="password-modal" class="admin-modal">
<div class="admin-modal-box">
<div class="admin-modal-header">
<h2 class="admin-modal-title">비밀번호 변경</h2>
<p id="password-modal-sub" class="admin-modal-sub"></p>
</div>
<div class="admin-modal-content">
<input type="hidden" id="password-user-id" />
<div class="admin-field">
<label class="admin-label" for="password-new">새 비밀번호</label>
<input type="password" id="password-new" class="admin-input" placeholder="8자 이상" autocomplete="new-password" />
</div>
<div class="admin-field">
<label class="admin-label" for="password-confirm">비밀번호 확인</label>
<input type="password" id="password-confirm" class="admin-input" placeholder="다시 입력" autocomplete="new-password" />
</div>
<p id="password-error" class="admin-error hidden"></p>
<div class="admin-modal-actions-row">
<button type="button" id="password-cancel" class="admin-btn admin-btn-secondary">취소</button>
<button type="button" id="password-save" class="admin-btn admin-btn-primary">변경</button>
</div>
</div>
</div>
</div>
<script src="/script/api.js"></script>
<script src="/script/image-utils.js"></script>
<script src="/script/auth-header.js"></script>
<script>
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>',
);
const editModal = document.getElementById('edit-modal');
const passwordModal = document.getElementById('password-modal');
let currentAdminId = null;
let editingUser = null;
let pendingAvatarImage = null;
let pendingRemoveAvatar = false;
function escapeHtml(str) {
return String(str ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function resolveAvatarSrc(userOrUrl) {
const avatarUrl = typeof userOrUrl === 'object' ? userOrUrl?.avatarUrl : userOrUrl;
const updatedAt = typeof userOrUrl === 'object' ? userOrUrl?.updatedAt : null;
if (!avatarUrl) return DEFAULT_AVATAR;
let url = UaDeckApi.resolveMediaUrl(avatarUrl) || DEFAULT_AVATAR;
if (updatedAt && url && !url.startsWith('data:')) {
url += (url.includes('?') ? '&' : '?') + 't=' + encodeURIComponent(updatedAt);
}
return url;
}
async function guardAdmin() {
if (!UaDeckApi.getToken()) {
location.href = '/login.html?redirect=/admin.html';
return false;
}
try {
const { user } = await UaDeckApi.getMe();
if (user.role !== 'admin') {
alert('관리자 권한이 필요합니다.');
location.href = '/';
return false;
}
currentAdminId = user.id;
return true;
} catch {
location.href = '/login.html?redirect=/admin.html';
return false;
}
}
async function loadUsers(q = '') {
const { users, total } = await UaDeckApi.adminGetUsers(q);
const list = document.getElementById('user-list');
list.innerHTML =
`<p class="admin-count">총 ${total}명</p>` +
users
.map((u) => {
const isSelf = u.id === currentAdminId;
return `
<article class="admin-card">
<div class="admin-user-card">
<img src="${escapeHtml(resolveAvatarSrc(u))}" alt="" class="admin-user-avatar" onerror="this.src='${DEFAULT_AVATAR}'" />
<div class="admin-user-body">
<h3 class="admin-card-title">${escapeHtml(u.username)}${isSelf ? ' <span class="admin-badge admin-badge-type">나</span>' : ''}</h3>
<div class="admin-card-meta">${escapeHtml(u.email)}</div>
<div class="admin-card-badges">
<span class="admin-badge admin-badge-type">${escapeHtml(u.role)}</span>
<span class="admin-badge ${u.isActive ? 'admin-badge-public' : 'admin-badge-private'}">${u.isActive ? '활성' : '비활성'}</span>
</div>
</div>
<button type="button" data-edit='${JSON.stringify(u).replace(/'/g, '&#39;')}' class="admin-card-btn admin-card-btn-edit">수정</button>
</div>
</article>`;
})
.join('');
list.querySelectorAll('[data-edit]').forEach((btn) => {
btn.addEventListener('click', () => openEditModal(JSON.parse(btn.dataset.edit)));
});
}
function openEditModal(u) {
editingUser = u;
pendingAvatarImage = null;
pendingRemoveAvatar = false;
const isSelf = u.id === currentAdminId;
document.getElementById('edit-user-id').value = u.id;
document.getElementById('edit-email').value = u.email;
document.getElementById('edit-username').value = u.username;
document.getElementById('edit-role').value = u.role;
document.getElementById('edit-isActive').value = u.isActive ? '1' : '0';
document.getElementById('edit-modal-title').textContent = u.username;
document.getElementById('edit-modal-sub').textContent = u.email;
document.getElementById('edit-avatar-preview').src = resolveAvatarSrc(u);
document.getElementById('edit-avatar-remove').classList.toggle('hidden', !u.avatarUrl);
document.getElementById('edit-role').disabled = isSelf;
document.getElementById('edit-isActive').disabled = isSelf;
document.getElementById('edit-role-hint').classList.toggle('hidden', !isSelf);
document.getElementById('edit-status-hint').classList.toggle('hidden', !isSelf);
document.getElementById('modal-error').classList.add('hidden');
editModal.classList.add('open');
}
function renderDeckPreview(d) {
if (!d.preview_url) {
return '<div class="admin-deck-preview-placeholder">미리보기 없음</div>';
}
const src = UaDeckApi.resolveMediaUrl(d.preview_url);
const version = d.updatedAt ? `?v=${encodeURIComponent(d.updatedAt)}` : '';
return `<img src="${escapeHtml(src + version)}" alt="" loading="lazy" onerror="this.parentElement.innerHTML='<div class=\\'admin-deck-preview-placeholder\\'>미리보기 없음</div>'" />`;
}
async function loadAdminDecks() {
const { decks } = await UaDeckApi.adminGetDecks();
document.getElementById('deck-count').textContent = `총 ${decks.length}개`;
document.getElementById('admin-deck-list').innerHTML = decks
.map((d) => {
const publicBadge = d.isPublic
? '<span class="admin-badge admin-badge-public">공개</span>'
: '<span class="admin-badge admin-badge-private">비공개</span>';
return `
<article class="admin-card admin-deck-card">
<div class="admin-deck-preview">${renderDeckPreview(d)}</div>
<div class="admin-deck-body">
<h3 class="admin-card-title">${escapeHtml(d.name || d.seriesName || d.series)}</h3>
<div class="admin-card-meta">@${escapeHtml(d.username || '비회원')} · 조회 ${d.viewCount}</div>
<div class="admin-card-meta">/d/${escapeHtml(d.shareCode)}</div>
<div class="admin-card-badges">
<span class="admin-badge admin-badge-type">${escapeHtml(d.seriesName || d.series)}</span>
${publicBadge}
</div>
<div class="admin-card-actions" style="margin-top:0.5rem">
<button type="button" data-del="${escapeHtml(d.shareCode)}" class="admin-card-btn admin-card-btn-delete">삭제</button>
</div>
</div>
</article>`;
})
.join('');
document.querySelectorAll('[data-del]').forEach((btn) => {
btn.addEventListener('click', async () => {
if (!confirm('삭제하시겠습니까?')) return;
await UaDeckApi.adminDeleteDeck(btn.dataset.del);
loadAdminDecks();
});
});
}
document.getElementById('edit-avatar-btn').addEventListener('click', () => {
document.getElementById('edit-avatar-file').click();
});
document.getElementById('edit-avatar-file').addEventListener('change', async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
pendingAvatarImage = await UaImageUtils.resizeImageToPng(file);
pendingRemoveAvatar = false;
document.getElementById('edit-avatar-preview').src = pendingAvatarImage;
document.getElementById('edit-avatar-remove').classList.remove('hidden');
} catch (err) {
alert(err.message || '이미지 처리에 실패했습니다.');
}
e.target.value = '';
});
document.getElementById('edit-avatar-remove').addEventListener('click', () => {
pendingAvatarImage = null;
pendingRemoveAvatar = true;
document.getElementById('edit-avatar-preview').src = DEFAULT_AVATAR;
document.getElementById('edit-avatar-remove').classList.add('hidden');
});
document.getElementById('open-password-modal').addEventListener('click', () => {
if (!editingUser) return;
document.getElementById('password-user-id').value = editingUser.id;
document.getElementById('password-modal-sub').textContent = `${editingUser.username} (${editingUser.email})`;
document.getElementById('password-new').value = '';
document.getElementById('password-confirm').value = '';
document.getElementById('password-error').classList.add('hidden');
passwordModal.classList.add('open');
});
document.getElementById('password-cancel').addEventListener('click', () => {
passwordModal.classList.remove('open');
});
document.getElementById('password-save').addEventListener('click', async () => {
const errEl = document.getElementById('password-error');
errEl.classList.add('hidden');
const pw = document.getElementById('password-new').value;
const confirm = document.getElementById('password-confirm').value;
if (pw.length < 8) {
errEl.textContent = '비밀번호는 8자 이상이어야 합니다.';
errEl.classList.remove('hidden');
return;
}
if (pw !== confirm) {
errEl.textContent = '비밀번호가 일치하지 않습니다.';
errEl.classList.remove('hidden');
return;
}
try {
await UaDeckApi.adminResetPassword(document.getElementById('password-user-id').value, pw);
passwordModal.classList.remove('open');
alert('비밀번호가 변경되었습니다.');
} catch (err) {
errEl.textContent = err.message;
errEl.classList.remove('hidden');
}
});
document.querySelectorAll('.admin-tab').forEach((btn) => {
btn.addEventListener('click', () => {
document.querySelectorAll('.admin-tab').forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
document.getElementById('tab-users').classList.toggle('hidden', btn.dataset.tab !== 'users');
document.getElementById('tab-decks').classList.toggle('hidden', btn.dataset.tab !== 'decks');
});
});
document.getElementById('user-search-btn').addEventListener('click', () => {
loadUsers(document.getElementById('user-search').value);
});
document.getElementById('modal-cancel').addEventListener('click', () => {
editModal.classList.remove('open');
});
document.getElementById('modal-save').addEventListener('click', async () => {
const errEl = document.getElementById('modal-error');
errEl.classList.add('hidden');
const id = document.getElementById('edit-user-id').value;
const isSelf = Number(id) === currentAdminId;
const body = {
email: document.getElementById('edit-email').value,
username: document.getElementById('edit-username').value,
};
if (!isSelf) {
body.role = document.getElementById('edit-role').value;
body.isActive = document.getElementById('edit-isActive').value === '1';
}
if (pendingAvatarImage) body.avatarImage = pendingAvatarImage;
if (pendingRemoveAvatar) body.removeAvatar = true;
try {
await UaDeckApi.adminUpdateUser(id, body);
editModal.classList.remove('open');
loadUsers(document.getElementById('user-search').value);
if (isSelf && window.UaDeckAuth?.refresh) {
await window.UaDeckAuth.refresh();
}
} catch (err) {
errEl.textContent = err.message;
errEl.classList.remove('hidden');
}
});
[editModal, passwordModal].forEach((modal) => {
modal.addEventListener('click', (e) => {
if (e.target === modal) modal.classList.remove('open');
});
});
document.addEventListener('DOMContentLoaded', async () => {
if (await guardAdmin()) {
loadUsers();
loadAdminDecks();
}
});
</script>
</body>
</html>