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:
405
admin.html
Normal file
405
admin.html
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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, ''')}' 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>
|
||||
Reference in New Issue
Block a user