회원 탈퇴와 덱 일괄 삭제 추가
This commit is contained in:
151
admin.html
151
admin.html
@@ -3,7 +3,7 @@
|
||||
<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.15" />
|
||||
<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>
|
||||
@@ -47,6 +47,13 @@
|
||||
</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">
|
||||
@@ -119,6 +126,7 @@
|
||||
<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>
|
||||
<button type="button" id="open-user-delete-modal" class="mt-2 w-full cursor-pointer rounded-xl border border-red-200 bg-red-50 px-4 py-2.5 text-[0.8125rem] font-bold text-red-600 hover:bg-red-100">회원 탈퇴 처리</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>
|
||||
@@ -153,7 +161,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/script/api.js?v=4.0.15"></script>
|
||||
<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/auth-header.js"></script>
|
||||
<script>
|
||||
@@ -165,6 +188,7 @@
|
||||
|
||||
const editModal = document.getElementById('edit-modal');
|
||||
const passwordModal = document.getElementById('password-modal');
|
||||
const userDeleteModal = document.getElementById('user-delete-modal');
|
||||
|
||||
/**
|
||||
* 관리자 모달 표시 상태 변경
|
||||
@@ -180,6 +204,7 @@
|
||||
let pendingAvatarImage = null;
|
||||
let pendingRemoveAvatar = false;
|
||||
let adminDecks = [];
|
||||
const selectedAdminDecks = new Set();
|
||||
let isRoleEditorOpen = false;
|
||||
|
||||
function escapeHtml(str) {
|
||||
@@ -318,6 +343,7 @@
|
||||
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('open-user-delete-modal').classList.toggle('hidden', isSelf);
|
||||
document.getElementById('approve-email-verification').classList.toggle(
|
||||
'hidden',
|
||||
Boolean(u.emailVerifiedAt),
|
||||
@@ -400,6 +426,7 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -413,7 +440,12 @@
|
||||
: '<span class="deck-card-badge-private">비공개</span>';
|
||||
return `
|
||||
<article class="deck-card" data-code="${escapeHtml(d.shareCode)}">
|
||||
<div class="deck-card-preview">${renderDeckPreview(d)}</div>
|
||||
<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>
|
||||
<div class="deck-card-preview">${renderDeckPreview(d)}</div>
|
||||
</div>
|
||||
<div class="deck-card-body">
|
||||
<div class="deck-card-visibility">
|
||||
<span class="deck-card-series">${seriesLabel}</span>
|
||||
@@ -436,11 +468,45 @@
|
||||
})
|
||||
.join('')}</div>`;
|
||||
closeAdminDeckMenus();
|
||||
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();
|
||||
}
|
||||
@@ -524,6 +590,50 @@
|
||||
errEl.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('open-user-delete-modal').addEventListener('click', () => {
|
||||
if (!editingUser || editingUser.id === currentAdminId) return;
|
||||
const confirmationInput = document.getElementById('user-delete-confirmation');
|
||||
document.getElementById('user-delete-modal-sub').textContent =
|
||||
`${editingUser.username} (${editingUser.email})`;
|
||||
confirmationInput.value = '';
|
||||
document.getElementById('user-delete-error').classList.add('hidden');
|
||||
document.getElementById('user-delete-yes').disabled = true;
|
||||
setModalOpen(userDeleteModal, true);
|
||||
confirmationInput.focus();
|
||||
});
|
||||
|
||||
document.getElementById('user-delete-confirmation').addEventListener('input', (event) => {
|
||||
document.getElementById('user-delete-yes').disabled = event.target.value !== 'DELETE';
|
||||
});
|
||||
|
||||
document.getElementById('user-delete-no').addEventListener('click', () => {
|
||||
setModalOpen(userDeleteModal, false);
|
||||
});
|
||||
|
||||
document.getElementById('user-delete-yes').addEventListener('click', async () => {
|
||||
if (!editingUser || editingUser.id === currentAdminId) return;
|
||||
const confirmation = document.getElementById('user-delete-confirmation').value;
|
||||
const errorEl = document.getElementById('user-delete-error');
|
||||
const yesButton = document.getElementById('user-delete-yes');
|
||||
errorEl.classList.add('hidden');
|
||||
yesButton.disabled = true;
|
||||
try {
|
||||
await UaDeckApi.adminDeleteUser(editingUser.id, confirmation);
|
||||
setModalOpen(userDeleteModal, false);
|
||||
setModalOpen(editModal, false);
|
||||
editingUser = null;
|
||||
await Promise.all([
|
||||
loadUsers(document.getElementById('user-search').value),
|
||||
loadAdminDecks(),
|
||||
loadOrphanPreviews(),
|
||||
]);
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
errorEl.classList.remove('hidden');
|
||||
yesButton.disabled = confirmation !== 'DELETE';
|
||||
}
|
||||
});
|
||||
document.getElementById('toggle-role-editor').addEventListener('click', () => {
|
||||
if (!editingUser || editingUser.id === currentAdminId) return;
|
||||
setRoleEditorOpen(!isRoleEditorOpen);
|
||||
@@ -577,6 +687,17 @@
|
||||
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();
|
||||
@@ -602,6 +723,28 @@
|
||||
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;
|
||||
@@ -649,7 +792,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
[editModal, passwordModal].forEach((modal) => {
|
||||
[editModal, passwordModal, userDeleteModal].forEach((modal) => {
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) setModalOpen(modal, false);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user