Files
uniare.sori.studio/admin.html
zenn 5a56ddcede v4.0.10: 관리자 계정 변경 안전장치와 접속시간 지표 추가
역할 변경은 명시적으로 버튼을 눌렀을 때만 가능하게 제한하고, 마지막 접속시간과 비밀번호 분실 안내를 추가해 운영 관리성을 높인다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 18:24:45 +09:00

644 lines
38 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.9" />
<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>
<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>
</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="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>
<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');
/**
* 관리자 모달 표시 상태 변경
* @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 = [];
let isRoleEditorOpen = 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;
}
/**
* 날짜를 상대 시간 텍스트로 변환
* @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>
</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, '&#39;')}' 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('modal-error').classList.add('hidden');
setModalOpen(editModal, true);
}
function renderDeckPreview(d) {
if (!d.preview_url) {
return '<div class="flex h-full items-center justify-center bg-white text-[0.6875rem] font-semibold text-slate-400">미리보기 없음</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" class="h-full w-full bg-white object-contain" onerror="this.parentElement.innerHTML='<div class=\\'flex h-full items-center justify-center bg-white text-[0.6875rem] font-semibold text-slate-400\\'>미리보기 없음</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>';
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="deck-card-preview">${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">
<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();
}
async function loadAdminDecks() {
const { decks } = await UaDeckApi.adminGetDecks();
adminDecks = decks;
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('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 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()]);
}
});
document.addEventListener('click', (e) => {
if (!e.target.closest('#admin-deck-list .deck-card-more-wrap')) closeAdminDeckMenus();
});
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,
};
if (!isSelf) {
if (isRoleEditorOpen) {
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);
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>