관리자 덱 카드에서 썸네일 확대와 불러오기를 복구하고, 회원 수정은 역할 변경 메뉴 안에서 탈퇴를 선택해 2단계 확인 후 처리하도록 통합한다. Co-authored-by: Cursor <cursoragent@cursor.com>
803 lines
49 KiB
HTML
803 lines
49 KiB
HTML
<!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?v=4.0.16" />
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800;900&family=Noto+Sans+KR:wght@400;700;900&display=swap" rel="stylesheet" />
|
|
<title>관리자 - UNIARE</title>
|
|
</head>
|
|
<body class="app-page">
|
|
<nav class="app-nav">
|
|
<div class="app-nav-inner">
|
|
<a href="/" class="inline-flex items-center gap-2.5 no-underline">
|
|
<img src="/images/assets/uniare-h48.png" alt="UNIARE" class="app-brand-logo" height="32" />
|
|
<span class="text-base font-extrabold leading-tight text-slate-800">관리자</span>
|
|
</a>
|
|
<div id="auth-header-slot"></div>
|
|
</div>
|
|
</nav>
|
|
|
|
<div class="app-shell">
|
|
<main class="app-main w-full">
|
|
<header class="mb-8">
|
|
<h1 class="mb-2 text-3xl font-black tracking-tight text-slate-900">관리자</h1>
|
|
<p class="text-sm text-slate-500">회원과 저장된 덱을 관리합니다.</p>
|
|
</header>
|
|
|
|
<div class="mb-7 flex gap-1 border-b border-slate-200" role="tablist">
|
|
<button type="button" data-tab="users" role="tab" aria-selected="true" class="-mb-px cursor-pointer border-0 border-b-2 border-blue-600 bg-transparent px-4 py-3 text-sm font-bold text-blue-600 transition-colors hover:text-slate-600">회원 관리</button>
|
|
<button type="button" data-tab="decks" role="tab" aria-selected="false" class="-mb-px cursor-pointer border-0 border-b-2 border-transparent bg-transparent px-4 py-3 text-sm font-bold text-slate-400 transition-colors hover:text-slate-600">덱 관리</button>
|
|
</div>
|
|
|
|
<section id="tab-users">
|
|
<div class="mb-5 flex flex-wrap gap-3">
|
|
<input type="search" id="user-search" class="box-border min-w-0 flex-1 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-sm outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/15" placeholder="이메일, 사용자명 검색..." />
|
|
<button type="button" id="user-search-btn" class="cursor-pointer rounded-xl border-0 bg-slate-800 px-5 py-2.5 text-sm font-bold text-white transition hover:bg-slate-700">검색</button>
|
|
</div>
|
|
<div id="user-list" class="grid grid-cols-1 gap-3 sm:grid-cols-2"></div>
|
|
</section>
|
|
|
|
<section id="tab-decks" class="hidden">
|
|
<div class="deck-history-toolbar mb-6">
|
|
<div class="deck-toolbar-main-row">
|
|
<input type="search" id="admin-deck-search" class="deck-search-input" placeholder="덱 이름, 시리즈, 사용자 검색..." />
|
|
<select id="admin-deck-series-filter" class="deck-series-select" aria-label="시리즈 필터">
|
|
<option value="">모든 시리즈</option>
|
|
</select>
|
|
<span id="deck-count" class="deck-count-label"></span>
|
|
</div>
|
|
<div class="mt-3 flex flex-wrap items-center justify-between gap-3 border-t border-slate-100 pt-3">
|
|
<label class="flex cursor-pointer items-center gap-2 text-xs font-semibold text-slate-500">
|
|
<input type="checkbox" id="admin-deck-select-visible" class="size-4 rounded border-slate-300 accent-blue-600" />
|
|
현재 목록 전체 선택
|
|
</label>
|
|
<button type="button" id="admin-deck-bulk-delete" class="cursor-pointer rounded-xl border-0 bg-red-600 px-4 py-2 text-xs font-bold text-white hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-slate-300" disabled>선택 덱 삭제</button>
|
|
</div>
|
|
</div>
|
|
<div id="admin-deck-list"></div>
|
|
<section class="mt-8 flex flex-col items-stretch justify-between gap-4 rounded-2xl border border-slate-200 bg-white px-5 py-4 shadow-sm sm:flex-row sm:items-center">
|
|
<div>
|
|
<h2 class="mb-1 text-[0.9375rem] font-bold">미참조 썸네일</h2>
|
|
<p id="orphan-preview-status" class="m-0 text-xs text-slate-500">확인 전</p>
|
|
</div>
|
|
<div class="flex shrink-0 gap-2">
|
|
<button type="button" id="orphan-preview-refresh" class="flex-1 cursor-pointer rounded-xl border-0 bg-slate-100 px-4 py-2.5 text-[0.8125rem] font-bold text-slate-600 hover:bg-slate-200">새로 확인</button>
|
|
<button type="button" id="orphan-preview-delete" class="flex-1 cursor-pointer rounded-xl border-0 bg-red-600 px-4 py-2.5 text-[0.8125rem] font-bold text-white disabled:cursor-not-allowed disabled:bg-slate-300" disabled>모두 정리</button>
|
|
</div>
|
|
</section>
|
|
<div id="orphan-preview-list" class="mt-3 hidden max-h-56 overflow-y-auto rounded-xl border border-slate-200 bg-white px-4 py-2"></div>
|
|
</section>
|
|
</main>
|
|
</div>
|
|
|
|
<!-- 회원 정보 모달 -->
|
|
<div id="edit-modal" class="fixed inset-0 z-[100] hidden items-center justify-center bg-slate-900/50 p-4">
|
|
<div class="w-full max-w-[26rem] overflow-hidden rounded-[1.25rem] bg-white shadow-2xl">
|
|
<div class="border-b border-slate-200 bg-gradient-to-br from-slate-50 to-blue-50 px-6 pb-4 pt-6">
|
|
<div class="flex items-center gap-4">
|
|
<div class="relative shrink-0">
|
|
<img id="edit-avatar-preview" class="h-[4.5rem] w-[4.5rem] rounded-full border-[3px] border-white bg-white object-cover shadow-md" alt="" />
|
|
<button type="button" id="edit-avatar-btn" class="absolute -bottom-1 -right-1 flex h-7 w-7 cursor-pointer items-center justify-center rounded-full border-2 border-white bg-blue-600 text-xs text-white" title="사진 변경">📷</button>
|
|
<input type="file" id="edit-avatar-file" accept="image/*" class="hidden" />
|
|
</div>
|
|
<div>
|
|
<h2 id="edit-modal-title" class="m-0 text-lg font-extrabold text-slate-900">회원 정보</h2>
|
|
<p id="edit-modal-sub" class="mt-1 text-xs text-slate-500"></p>
|
|
</div>
|
|
</div>
|
|
<button type="button" id="edit-avatar-remove" class="mt-2 hidden cursor-pointer border-0 bg-transparent p-0 text-[0.6875rem] text-slate-400 underline">프로필 사진 제거</button>
|
|
</div>
|
|
<div class="px-6 pb-6 pt-5">
|
|
<input type="hidden" id="edit-user-id" />
|
|
<div class="mb-4">
|
|
<label class="mb-1.5 block text-[0.6875rem] font-bold uppercase text-slate-400" for="edit-email">이메일</label>
|
|
<input type="email" id="edit-email" class="w-full box-border rounded-xl border border-slate-200 px-3 py-2.5 text-sm disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-400" autocomplete="off" />
|
|
</div>
|
|
<div class="mb-4">
|
|
<label class="mb-1.5 block text-[0.6875rem] font-bold uppercase text-slate-400" for="edit-username">사용자명</label>
|
|
<input type="text" id="edit-username" class="w-full box-border rounded-xl border border-slate-200 px-3 py-2.5 text-sm disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-400" autocomplete="off" />
|
|
</div>
|
|
<div class="mb-4">
|
|
<label class="mb-1.5 block text-[0.6875rem] font-bold uppercase text-slate-400">역할</label>
|
|
<div class="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5">
|
|
<div class="flex items-center justify-between gap-2">
|
|
<span id="edit-role-summary" class="text-sm font-semibold text-slate-700"></span>
|
|
<button type="button" id="toggle-role-editor" class="cursor-pointer rounded-lg border-0 bg-slate-200 px-2.5 py-1 text-[0.6875rem] font-bold text-slate-600 hover:bg-slate-300">역할 변경하기</button>
|
|
</div>
|
|
<div id="edit-role-editor" class="mt-2 hidden">
|
|
<select id="edit-role" class="w-full box-border rounded-xl border border-slate-200 bg-white px-3 py-2.5 text-sm disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-400">
|
|
<option value="user">일반 회원</option>
|
|
<option value="admin">관리자</option>
|
|
<option value="delete_user">회원 탈퇴 처리</option>
|
|
</select>
|
|
<p id="edit-role-hint" class="mt-1.5 hidden text-[0.6875rem] leading-snug text-amber-600">본인 계정의 관리자 권한은 변경할 수 없습니다.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="mb-4">
|
|
<label class="mb-1.5 block text-[0.6875rem] font-bold uppercase text-slate-400" for="edit-isActive">상태</label>
|
|
<select id="edit-isActive" class="w-full box-border rounded-xl border border-slate-200 px-3 py-2.5 text-sm disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-400">
|
|
<option value="1">활성</option>
|
|
<option value="0">비활성</option>
|
|
</select>
|
|
<p id="edit-status-hint" class="mt-1.5 hidden text-[0.6875rem] leading-snug text-amber-600">본인 계정은 비활성화할 수 없습니다.</p>
|
|
</div>
|
|
<p id="modal-error" class="mt-2 hidden text-[0.8125rem] text-red-500"></p>
|
|
<div class="my-4 h-px bg-slate-200"></div>
|
|
<button type="button" id="approve-email-verification" class="mb-2 hidden w-full cursor-pointer rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-2.5 text-[0.8125rem] font-bold text-emerald-700 hover:bg-emerald-100">이메일 인증 승인</button>
|
|
<button type="button" id="open-password-modal" class="w-full cursor-pointer rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-[0.8125rem] font-bold text-slate-600 hover:bg-slate-50">비밀번호 변경하기</button>
|
|
<div class="mt-3 flex gap-2">
|
|
<button type="button" id="modal-cancel" class="flex-1 cursor-pointer rounded-xl border-0 bg-slate-100 px-4 py-2.5 text-[0.8125rem] font-bold text-slate-600 hover:bg-slate-200">취소</button>
|
|
<button type="button" id="modal-save" class="flex-1 cursor-pointer rounded-xl border-0 bg-slate-800 px-4 py-2.5 text-[0.8125rem] font-bold text-white hover:bg-slate-700">저장</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 비밀번호 변경 모달 -->
|
|
<div id="password-modal" class="fixed inset-0 z-[100] hidden items-center justify-center bg-slate-900/50 p-4">
|
|
<div class="w-full max-w-[26rem] overflow-hidden rounded-[1.25rem] bg-white shadow-2xl">
|
|
<div class="border-b border-slate-200 bg-gradient-to-br from-slate-50 to-blue-50 px-6 pb-4 pt-6">
|
|
<h2 class="m-0 text-lg font-extrabold text-slate-900">비밀번호 변경</h2>
|
|
<p id="password-modal-sub" class="mt-1 text-xs text-slate-500"></p>
|
|
</div>
|
|
<div class="px-6 pb-6 pt-5">
|
|
<input type="hidden" id="password-user-id" />
|
|
<div class="mb-4">
|
|
<label class="mb-1.5 block text-[0.6875rem] font-bold uppercase text-slate-400" for="password-new">새 비밀번호</label>
|
|
<input type="password" id="password-new" class="w-full box-border rounded-xl border border-slate-200 px-3 py-2.5 text-sm" placeholder="8자 이상" autocomplete="new-password" />
|
|
</div>
|
|
<div class="mb-4">
|
|
<label class="mb-1.5 block text-[0.6875rem] font-bold uppercase text-slate-400" for="password-confirm">비밀번호 확인</label>
|
|
<input type="password" id="password-confirm" class="w-full box-border rounded-xl border border-slate-200 px-3 py-2.5 text-sm" placeholder="다시 입력" autocomplete="new-password" />
|
|
</div>
|
|
<p id="password-error" class="mt-2 hidden text-[0.8125rem] text-red-500"></p>
|
|
<div class="flex gap-2">
|
|
<button type="button" id="password-cancel" class="flex-1 cursor-pointer rounded-xl border-0 bg-slate-100 px-4 py-2.5 text-[0.8125rem] font-bold text-slate-600 hover:bg-slate-200">취소</button>
|
|
<button type="button" id="password-save" class="flex-1 cursor-pointer rounded-xl border-0 bg-slate-800 px-4 py-2.5 text-[0.8125rem] font-bold text-white hover:bg-slate-700">변경</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="user-delete-modal" class="fixed inset-0 z-[110] hidden items-center justify-center bg-slate-900/50 p-4">
|
|
<div class="w-full max-w-[26rem] rounded-[1.25rem] bg-white p-6 shadow-2xl">
|
|
<h2 class="m-0 text-lg font-extrabold text-slate-900">회원 탈퇴 처리</h2>
|
|
<p id="user-delete-modal-sub" class="mt-2 text-sm leading-relaxed text-slate-500"></p>
|
|
<p class="mt-3 text-xs leading-relaxed text-slate-500">계정과 개인 정보는 삭제되며 저장한 덱은 익명으로 유지됩니다. 복구할 수 없습니다.</p>
|
|
<label class="mt-4 block text-[0.6875rem] font-bold uppercase text-slate-400" for="user-delete-confirmation">확인을 위해 DELETE 입력</label>
|
|
<input type="text" id="user-delete-confirmation" class="mt-1.5 w-full box-border rounded-xl border border-slate-200 px-3 py-2.5 text-sm" autocomplete="off" placeholder="DELETE" />
|
|
<p id="user-delete-error" class="mt-2 hidden text-[0.8125rem] text-red-500"></p>
|
|
<div class="mt-5 flex gap-2">
|
|
<button type="button" id="user-delete-no" class="flex-1 cursor-pointer rounded-xl border-0 bg-slate-100 px-4 py-2.5 text-sm font-bold text-slate-600 hover:bg-slate-200">NO</button>
|
|
<button type="button" id="user-delete-yes" class="flex-1 cursor-pointer rounded-xl border-0 bg-red-600 px-4 py-2.5 text-sm font-bold text-white hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-slate-300" disabled>YES</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="/script/api.js?v=4.0.16"></script>
|
|
<script src="/script/image-utils.js"></script>
|
|
<script src="/script/deck-preview-hold.js?v=4.0.17"></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');
|
|
|
|
/**
|
|
* 관리자 모달 표시 상태 변경
|
|
* @param {HTMLElement} modal 대상 모달
|
|
* @param {boolean} isOpen 표시 여부
|
|
*/
|
|
function setModalOpen(modal, isOpen) {
|
|
modal.classList.toggle('hidden', !isOpen);
|
|
modal.classList.toggle('flex', isOpen);
|
|
}
|
|
let currentAdminId = null;
|
|
let editingUser = null;
|
|
let pendingAvatarImage = null;
|
|
let pendingRemoveAvatar = false;
|
|
let adminDecks = [];
|
|
const selectedAdminDecks = new Set();
|
|
let isRoleEditorOpen = 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;
|
|
}
|
|
|
|
/**
|
|
* 날짜를 상대 시간 텍스트로 변환
|
|
* @param {string|null|undefined} value
|
|
* @returns {string}
|
|
*/
|
|
function formatRelativeTime(value) {
|
|
if (!value) return '기록 없음';
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return '기록 없음';
|
|
|
|
const diffMs = Date.now() - date.getTime();
|
|
if (diffMs < 0) return '방금 전';
|
|
|
|
const minuteMs = 60 * 1000;
|
|
const hourMs = 60 * minuteMs;
|
|
const dayMs = 24 * hourMs;
|
|
const monthMs = 30 * dayMs;
|
|
|
|
if (diffMs < minuteMs) return '방금 전';
|
|
if (diffMs < hourMs) return `${Math.floor(diffMs / minuteMs)}분 전`;
|
|
if (diffMs < dayMs) return `${Math.floor(diffMs / hourMs)}시간 전`;
|
|
if (diffMs < monthMs) return `${Math.floor(diffMs / dayMs)}일 전`;
|
|
return `${Math.floor(diffMs / monthMs)}달 전`;
|
|
}
|
|
|
|
/**
|
|
* 역할 변경 입력창 표시 여부
|
|
* @param {boolean} isOpen
|
|
*/
|
|
function setRoleEditorOpen(isOpen) {
|
|
isRoleEditorOpen = isOpen;
|
|
document.getElementById('edit-role-editor').classList.toggle('hidden', !isOpen);
|
|
document.getElementById('toggle-role-editor').textContent = isOpen ? '역할 변경 닫기' : '역할 변경하기';
|
|
}
|
|
|
|
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="col-span-full mb-1 text-xs text-slate-400">총 ${total}명</p>` +
|
|
users
|
|
.map((u) => {
|
|
const isSelf = u.id === currentAdminId;
|
|
const deckPublic = Number(u.deckPublic ?? 0);
|
|
const deckTotal = Number(u.deckTotal ?? 0);
|
|
return `
|
|
<article class="rounded-2xl border border-slate-200 bg-white px-4 py-3.5 shadow-sm">
|
|
<div class="flex items-start gap-3">
|
|
<img src="${escapeHtml(resolveAvatarSrc(u))}" alt="" class="h-11 w-11 shrink-0 rounded-full border-2 border-slate-200 bg-slate-100 object-cover" onerror="this.src='${DEFAULT_AVATAR}'" />
|
|
<div class="min-w-0 flex-1">
|
|
<h3 class="mb-0.5 text-sm font-bold leading-snug text-slate-900">${escapeHtml(u.username)}${isSelf ? ' <span class="rounded-md bg-blue-50 px-1.5 py-0.5 text-[0.625rem] font-bold text-blue-600">나</span>' : ''}</h3>
|
|
<div class="truncate text-xs text-slate-400">${escapeHtml(u.email)}</div>
|
|
<div class="mt-1 text-[0.6875rem] text-slate-500">마지막 접속: ${escapeHtml(formatRelativeTime(u.lastLoginAt))}</div>
|
|
<div class="mt-1.5 flex flex-wrap justify-between gap-1">
|
|
<div class="flex gap-1">
|
|
<span class="rounded-md bg-blue-50 px-1.5 py-0.5 text-[0.625rem] font-bold text-blue-600">${escapeHtml(u.role)}</span>
|
|
<span class="rounded-md px-1.5 py-0.5 text-[0.625rem] font-bold ${u.isActive ? 'bg-emerald-50 text-emerald-600' : 'bg-slate-100 text-slate-500'}">${u.isActive ? '활성' : '비활성'}</span>
|
|
<span class="rounded-md px-1.5 py-0.5 text-[0.625rem] font-bold ${u.emailVerifiedAt ? 'bg-emerald-50 text-emerald-600' : 'bg-amber-50 text-amber-700'}">${u.emailVerifiedAt ? '이메일 인증' : '이메일 미인증'}</span>
|
|
</div>
|
|
<div class="mt-1.5 text-xs font-semibold text-slate-600" title="공개 덱 / 전체 저장 덱">덱 ${deckPublic}/${deckTotal} <span class="font-normal text-slate-400">(공개/전체)</span></div>
|
|
</div>
|
|
</div>
|
|
<button type="button" data-edit='${JSON.stringify(u).replace(/'/g, ''')}' class="shrink-0 cursor-pointer rounded-lg border-0 bg-slate-100 px-2.5 py-1.5 text-[0.6875rem] font-bold text-slate-600 hover:bg-slate-200">수정</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-role-summary').textContent = u.role === 'admin' ? '관리자' : '일반 회원';
|
|
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);
|
|
setRoleEditorOpen(false);
|
|
|
|
document.getElementById('edit-role').disabled = isSelf;
|
|
document.getElementById('edit-isActive').disabled = isSelf;
|
|
document.getElementById('toggle-role-editor').disabled = isSelf;
|
|
document.getElementById('toggle-role-editor').classList.toggle('hidden', isSelf);
|
|
document.getElementById('edit-role-hint').classList.toggle('hidden', !isSelf);
|
|
document.getElementById('edit-status-hint').classList.toggle('hidden', !isSelf);
|
|
document.getElementById('approve-email-verification').classList.toggle(
|
|
'hidden',
|
|
Boolean(u.emailVerifiedAt),
|
|
);
|
|
document.getElementById('modal-error').classList.add('hidden');
|
|
|
|
setModalOpen(editModal, true);
|
|
}
|
|
|
|
function renderDeckPreview(d) {
|
|
if (!d.preview_url) {
|
|
return '<div class="deck-card-preview"><div class="flex h-full items-center justify-center bg-white text-[0.6875rem] font-semibold text-slate-400">미리보기 없음</div></div>';
|
|
}
|
|
const src = UaDeckApi.resolveMediaUrl(d.preview_url);
|
|
const version = d.updatedAt ? `?v=${encodeURIComponent(d.updatedAt)}` : '';
|
|
const previewSrc = escapeHtml(src + version);
|
|
return `<div class="deck-card-preview deck-card-preview--holdable" data-hold-preview data-preview-src="${previewSrc}"><img src="${previewSrc}" alt="" loading="lazy" class="admin-deck-preview-img h-full w-full bg-white object-contain" draggable="false" onerror="this.parentElement.classList.remove('deck-card-preview--holdable'); this.parentElement.removeAttribute('data-hold-preview'); this.parentElement.removeAttribute('data-preview-src'); this.parentElement.innerHTML='<div class=\\'flex h-full items-center justify-center bg-white text-[0.6875rem] font-semibold text-slate-400\\'>미리보기 없음</div>'" /></div>`;
|
|
}
|
|
|
|
/**
|
|
* 바이트 크기 표시
|
|
* @param {number} bytes
|
|
* @returns {string}
|
|
*/
|
|
function formatBytes(bytes) {
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
}
|
|
|
|
/**
|
|
* 관리자 덱 시리즈 필터 갱신
|
|
*/
|
|
function populateAdminSeriesFilter() {
|
|
const select = document.getElementById('admin-deck-series-filter');
|
|
const current = select.value;
|
|
const seriesMap = new Map();
|
|
adminDecks.forEach((deck) => {
|
|
if (!seriesMap.has(deck.series)) {
|
|
seriesMap.set(deck.series, deck.seriesName || deck.series);
|
|
}
|
|
});
|
|
select.innerHTML =
|
|
'<option value="">모든 시리즈</option>' +
|
|
[...seriesMap.entries()]
|
|
.sort((a, b) => String(a[1]).localeCompare(String(b[1]), 'ko'))
|
|
.map(([key, label]) => `<option value="${escapeHtml(key)}">${escapeHtml(label)}</option>`)
|
|
.join('');
|
|
if (current && seriesMap.has(current)) select.value = current;
|
|
}
|
|
|
|
/**
|
|
* 열린 관리자 덱 메뉴 닫기
|
|
*/
|
|
function closeAdminDeckMenus() {
|
|
document.querySelectorAll('#admin-deck-list .deck-card-more-menu').forEach((menu) => {
|
|
menu.classList.add('hidden');
|
|
});
|
|
document.querySelectorAll('#admin-deck-list .deck-card-more-btn').forEach((btn) => {
|
|
btn.setAttribute('aria-expanded', 'false');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 관리자 덱 목록 렌더링
|
|
*/
|
|
function renderAdminDecks() {
|
|
const query = document.getElementById('admin-deck-search').value.trim().toLowerCase();
|
|
const series = document.getElementById('admin-deck-series-filter').value;
|
|
const filtered = adminDecks.filter((deck) => {
|
|
if (series && deck.series !== series) return false;
|
|
if (!query) return true;
|
|
return [deck.name, deck.seriesName, deck.series, deck.username]
|
|
.filter(Boolean)
|
|
.join(' ')
|
|
.toLowerCase()
|
|
.includes(query);
|
|
});
|
|
|
|
document.getElementById('deck-count').textContent = `${filtered.length} / ${adminDecks.length}개`;
|
|
const list = document.getElementById('admin-deck-list');
|
|
if (!filtered.length) {
|
|
list.innerHTML = '<div class="deck-grid"><div class="deck-grid-empty">조건에 맞는 덱이 없습니다.</div></div>';
|
|
updateBulkDeleteControls(filtered);
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = `<div class="deck-grid">${filtered
|
|
.map((d) => {
|
|
const title = escapeHtml(d.name || d.seriesName || d.series || '이름 없음');
|
|
const seriesLabel = escapeHtml(d.seriesName || d.series);
|
|
const date = new Date(d.updatedAt).toLocaleDateString('ko-KR');
|
|
const visibilityBadge = d.isPublic
|
|
? '<span class="deck-card-badge-public">공개</span>'
|
|
: '<span class="deck-card-badge-private">비공개</span>';
|
|
return `
|
|
<article class="deck-card" data-code="${escapeHtml(d.shareCode)}">
|
|
<div class="relative">
|
|
<label class="absolute left-2 top-2 z-10 flex cursor-pointer items-center rounded-lg bg-white/90 p-1.5 shadow-sm" title="덱 선택">
|
|
<input type="checkbox" class="admin-deck-select size-4 rounded border-slate-300 accent-blue-600" data-share-code="${escapeHtml(d.shareCode)}" ${selectedAdminDecks.has(d.shareCode) ? 'checked' : ''} />
|
|
</label>
|
|
${renderDeckPreview(d)}
|
|
</div>
|
|
<div class="deck-card-body">
|
|
<div class="deck-card-visibility">
|
|
<span class="deck-card-series">${seriesLabel}</span>
|
|
${visibilityBadge}
|
|
</div>
|
|
<h2 class="deck-card-title">${title}</h2>
|
|
<div class="deck-card-meta">${escapeHtml(d.username || '비회원')} · ${date}</div>
|
|
<div class="deck-card-actions justify-end">
|
|
<button type="button" class="deck-card-btn deck-card-btn-load" data-admin-load="${escapeHtml(d.shareCode)}" data-series="${escapeHtml(d.series)}" data-name="${escapeHtml(d.seriesName || '')}">불러오기</button>
|
|
<div class="deck-card-more-wrap">
|
|
<button type="button" class="deck-card-more-btn" aria-haspopup="true" aria-expanded="false" aria-label="더 보기">
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><circle cx="8" cy="3" r="1.5"/><circle cx="8" cy="8" r="1.5"/><circle cx="8" cy="13" r="1.5"/></svg>
|
|
</button>
|
|
<div class="deck-card-more-menu hidden" role="menu">
|
|
<button type="button" class="deck-card-more-item deck-card-more-item--danger" role="menuitem" data-admin-delete="${escapeHtml(d.shareCode)}">삭제</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</article>`;
|
|
})
|
|
.join('')}</div>`;
|
|
closeAdminDeckMenus();
|
|
if (window.UaDeckPreviewHold?.bindHoldPreview) {
|
|
window.UaDeckPreviewHold.bindHoldPreview(list);
|
|
}
|
|
updateBulkDeleteControls(filtered);
|
|
}
|
|
|
|
/**
|
|
* 일괄 삭제 선택 상태 갱신
|
|
* @param {object[]} [visibleDecks]
|
|
*/
|
|
function updateBulkDeleteControls(visibleDecks = null) {
|
|
const visible = visibleDecks || adminDecks.filter((deck) => {
|
|
const query = document.getElementById('admin-deck-search').value.trim().toLowerCase();
|
|
const series = document.getElementById('admin-deck-series-filter').value;
|
|
if (series && deck.series !== series) return false;
|
|
if (!query) return true;
|
|
return [deck.name, deck.seriesName, deck.series, deck.username]
|
|
.filter(Boolean)
|
|
.join(' ')
|
|
.toLowerCase()
|
|
.includes(query);
|
|
});
|
|
const visibleCodes = visible.map((deck) => deck.shareCode);
|
|
const selectedVisibleCount = visibleCodes.filter((code) => selectedAdminDecks.has(code)).length;
|
|
const selectAll = document.getElementById('admin-deck-select-visible');
|
|
selectAll.checked = visibleCodes.length > 0 && selectedVisibleCount === visibleCodes.length;
|
|
selectAll.indeterminate = selectedVisibleCount > 0 && selectedVisibleCount < visibleCodes.length;
|
|
document.getElementById('admin-deck-bulk-delete').disabled = selectedAdminDecks.size === 0;
|
|
document.getElementById('admin-deck-bulk-delete').textContent =
|
|
selectedAdminDecks.size > 0
|
|
? `선택 덱 삭제 (${selectedAdminDecks.size})`
|
|
: '선택 덱 삭제';
|
|
}
|
|
|
|
async function loadAdminDecks() {
|
|
const { decks } = await UaDeckApi.adminGetDecks();
|
|
adminDecks = decks;
|
|
[...selectedAdminDecks].forEach((code) => {
|
|
if (!adminDecks.some((deck) => deck.shareCode === code)) {
|
|
selectedAdminDecks.delete(code);
|
|
}
|
|
});
|
|
populateAdminSeriesFilter();
|
|
renderAdminDecks();
|
|
}
|
|
|
|
/**
|
|
* 미참조 썸네일 현황 조회
|
|
*/
|
|
async function loadOrphanPreviews() {
|
|
const status = document.getElementById('orphan-preview-status');
|
|
const deleteBtn = document.getElementById('orphan-preview-delete');
|
|
try {
|
|
const data = await UaDeckApi.adminGetOrphanPreviews();
|
|
status.textContent = data.totalCount
|
|
? `${data.totalCount}개 · ${formatBytes(data.totalBytes)}`
|
|
: '정리할 파일이 없습니다.';
|
|
deleteBtn.disabled = data.totalCount === 0;
|
|
const list = document.getElementById('orphan-preview-list');
|
|
list.classList.toggle('hidden', data.totalCount === 0);
|
|
list.innerHTML = data.files
|
|
.map(
|
|
(file) =>
|
|
`<div class="flex flex-col gap-1 border-b border-slate-100 py-2 text-xs last:border-b-0 sm:flex-row sm:justify-between sm:gap-4"><span class="min-w-0 break-all text-slate-700">${escapeHtml(file.fileName)}</span><span class="shrink-0 text-slate-400">${formatBytes(file.size)} · ${new Date(file.modifiedAt).toLocaleDateString('ko-KR')}</span></div>`,
|
|
)
|
|
.join('');
|
|
} catch (err) {
|
|
status.textContent = err.message;
|
|
deleteBtn.disabled = true;
|
|
document.getElementById('orphan-preview-list').classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
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');
|
|
setModalOpen(passwordModal, true);
|
|
});
|
|
|
|
document.getElementById('approve-email-verification').addEventListener('click', async () => {
|
|
if (!editingUser || editingUser.emailVerifiedAt) return;
|
|
if (!confirm(`${editingUser.email} 계정의 이메일 인증을 승인하시겠습니까?`)) return;
|
|
|
|
const errEl = document.getElementById('modal-error');
|
|
errEl.classList.add('hidden');
|
|
try {
|
|
const result = await UaDeckApi.adminVerifyUserEmail(editingUser.id);
|
|
editingUser.emailVerifiedAt = result.emailVerifiedAt;
|
|
document.getElementById('approve-email-verification').classList.add('hidden');
|
|
await loadUsers(document.getElementById('user-search').value);
|
|
alert('이메일 인증을 승인했습니다.');
|
|
} catch (err) {
|
|
errEl.textContent = err.message;
|
|
errEl.classList.remove('hidden');
|
|
}
|
|
});
|
|
|
|
document.getElementById('toggle-role-editor').addEventListener('click', () => {
|
|
if (!editingUser || editingUser.id === currentAdminId) return;
|
|
setRoleEditorOpen(!isRoleEditorOpen);
|
|
});
|
|
|
|
document.getElementById('password-cancel').addEventListener('click', () => {
|
|
setModalOpen(passwordModal, false);
|
|
});
|
|
|
|
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);
|
|
setModalOpen(passwordModal, false);
|
|
alert('비밀번호가 변경되었습니다.');
|
|
} catch (err) {
|
|
errEl.textContent = err.message;
|
|
errEl.classList.remove('hidden');
|
|
}
|
|
});
|
|
|
|
document.querySelectorAll('[data-tab]').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
document.querySelectorAll('[data-tab]').forEach((tabButton) => {
|
|
const isActive = tabButton === btn;
|
|
tabButton.classList.toggle('border-blue-600', isActive);
|
|
tabButton.classList.toggle('text-blue-600', isActive);
|
|
tabButton.classList.toggle('border-transparent', !isActive);
|
|
tabButton.classList.toggle('text-slate-400', !isActive);
|
|
tabButton.setAttribute('aria-selected', isActive ? 'true' : 'false');
|
|
});
|
|
document.getElementById('tab-users').classList.toggle('hidden', btn.dataset.tab !== 'users');
|
|
document.getElementById('tab-decks').classList.toggle('hidden', btn.dataset.tab !== 'decks');
|
|
});
|
|
});
|
|
|
|
document.getElementById('admin-deck-search').addEventListener('input', renderAdminDecks);
|
|
document.getElementById('admin-deck-series-filter').addEventListener('change', renderAdminDecks);
|
|
|
|
document.getElementById('admin-deck-list').addEventListener('click', async (e) => {
|
|
const selection = e.target.closest('.admin-deck-select');
|
|
if (selection) {
|
|
if (selection.checked) {
|
|
selectedAdminDecks.add(selection.dataset.shareCode);
|
|
} else {
|
|
selectedAdminDecks.delete(selection.dataset.shareCode);
|
|
}
|
|
updateBulkDeleteControls();
|
|
return;
|
|
}
|
|
|
|
const moreBtn = e.target.closest('.deck-card-more-btn');
|
|
if (moreBtn) {
|
|
e.stopPropagation();
|
|
const menu = moreBtn.parentElement?.querySelector('.deck-card-more-menu');
|
|
const isOpen = menu && !menu.classList.contains('hidden');
|
|
closeAdminDeckMenus();
|
|
if (!isOpen && menu) {
|
|
menu.classList.remove('hidden');
|
|
moreBtn.setAttribute('aria-expanded', 'true');
|
|
}
|
|
return;
|
|
}
|
|
|
|
const deleteBtn = e.target.closest('[data-admin-delete]');
|
|
if (deleteBtn) {
|
|
if (!confirm('덱과 연결된 썸네일을 함께 삭제하시겠습니까?')) return;
|
|
await UaDeckApi.adminDeleteDeck(deleteBtn.dataset.adminDelete);
|
|
await Promise.all([loadAdminDecks(), loadOrphanPreviews()]);
|
|
return;
|
|
}
|
|
|
|
const loadBtn = e.target.closest('[data-admin-load]');
|
|
if (loadBtn) {
|
|
const code = loadBtn.dataset.adminLoad;
|
|
const series = loadBtn.dataset.series;
|
|
const name = loadBtn.dataset.name;
|
|
location.href = `/cardList.html?series=${series}&seriesName=${encodeURIComponent(name)}&d=${code}`;
|
|
}
|
|
});
|
|
|
|
document.addEventListener('click', (e) => {
|
|
if (!e.target.closest('#admin-deck-list .deck-card-more-wrap')) closeAdminDeckMenus();
|
|
});
|
|
|
|
document.getElementById('admin-deck-select-visible').addEventListener('change', (event) => {
|
|
document.querySelectorAll('.admin-deck-select').forEach((checkbox) => {
|
|
checkbox.checked = event.target.checked;
|
|
if (event.target.checked) {
|
|
selectedAdminDecks.add(checkbox.dataset.shareCode);
|
|
} else {
|
|
selectedAdminDecks.delete(checkbox.dataset.shareCode);
|
|
}
|
|
});
|
|
updateBulkDeleteControls();
|
|
});
|
|
|
|
document.getElementById('admin-deck-bulk-delete').addEventListener('click', async () => {
|
|
const shareCodes = [...selectedAdminDecks];
|
|
if (!shareCodes.length) return;
|
|
if (!confirm(`선택한 덱 ${shareCodes.length}개와 연결된 썸네일을 함께 삭제하시겠습니까?`)) return;
|
|
const result = await UaDeckApi.adminDeleteDecks(shareCodes);
|
|
selectedAdminDecks.clear();
|
|
alert(`${result.deletedCount}개 덱을 삭제했습니다.`);
|
|
await Promise.all([loadAdminDecks(), loadOrphanPreviews()]);
|
|
});
|
|
|
|
document.getElementById('orphan-preview-refresh').addEventListener('click', loadOrphanPreviews);
|
|
document.getElementById('orphan-preview-delete').addEventListener('click', async () => {
|
|
if (!confirm('어떤 덱에서도 사용하지 않는 썸네일을 모두 삭제하시겠습니까?')) return;
|
|
const result = await UaDeckApi.adminDeleteOrphanPreviews();
|
|
alert(`${result.deletedCount}개 파일을 정리했습니다.`);
|
|
loadOrphanPreviews();
|
|
});
|
|
|
|
document.getElementById('user-search-btn').addEventListener('click', () => {
|
|
loadUsers(document.getElementById('user-search').value);
|
|
});
|
|
|
|
document.getElementById('modal-cancel').addEventListener('click', () => {
|
|
setModalOpen(editModal, false);
|
|
});
|
|
|
|
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,
|
|
};
|
|
const selectedRole = document.getElementById('edit-role').value;
|
|
if (!isSelf) {
|
|
if (isRoleEditorOpen) {
|
|
if (selectedRole === 'delete_user') {
|
|
const targetLabel = `${document.getElementById('edit-username').value || editingUser?.username || ''} (${document.getElementById('edit-email').value || editingUser?.email || ''})`.trim();
|
|
const firstConfirm = confirm(`정말 ${targetLabel} 회원을 탈퇴 처리하시겠습니까?`);
|
|
if (!firstConfirm) return;
|
|
const secondConfirm = confirm('탈퇴 처리 후 계정 복구는 불가능합니다. 계속 진행할까요?');
|
|
if (!secondConfirm) return;
|
|
try {
|
|
await UaDeckApi.adminDeleteUser(id, 'DELETE');
|
|
setModalOpen(editModal, false);
|
|
editingUser = null;
|
|
await Promise.all([
|
|
loadUsers(document.getElementById('user-search').value),
|
|
loadAdminDecks(),
|
|
loadOrphanPreviews(),
|
|
]);
|
|
} catch (err) {
|
|
errEl.textContent = err.message;
|
|
errEl.classList.remove('hidden');
|
|
}
|
|
return;
|
|
}
|
|
body.role = selectedRole;
|
|
}
|
|
body.isActive = document.getElementById('edit-isActive').value === '1';
|
|
}
|
|
if (pendingAvatarImage) body.avatarImage = pendingAvatarImage;
|
|
if (pendingRemoveAvatar) body.removeAvatar = true;
|
|
|
|
try {
|
|
await UaDeckApi.adminUpdateUser(id, body);
|
|
setModalOpen(editModal, false);
|
|
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) setModalOpen(modal, false);
|
|
});
|
|
});
|
|
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
if (await guardAdmin()) {
|
|
loadUsers();
|
|
loadAdminDecks();
|
|
loadOrphanPreviews();
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|