관리자 기능과 계정 즐겨찾기 개선

This commit is contained in:
2026-06-23 14:32:43 +09:00
parent 00e05dbe3f
commit 93e81ce3ea
26 changed files with 804 additions and 914 deletions

View File

@@ -4,121 +4,141 @@
<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/brand.css" />
<link rel="stylesheet" href="./style/auth-header.css" />
<link rel="stylesheet" href="./style/admin.css" />
<link rel="stylesheet" href="./style/deck-history.css" />
<title>관리자 - UNIARE</title>
</head>
<body class="admin-page">
<nav class="admin-nav">
<div class="admin-nav-inner">
<a href="/" class="admin-brand">
<body class="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 font-['Pretendard','Inter','Noto_Sans_KR',sans-serif] text-slate-900">
<nav class="sticky top-0 z-50 border-b border-slate-200 bg-white/70 backdrop-blur-xl">
<div class="mx-auto flex h-16 max-w-7xl items-center justify-between gap-4 px-4 sm:px-6">
<a href="/" class="flex items-center gap-2.5 tracking-tight">
<img src="/images/assets/uniare-h48.png" alt="UNIARE" class="app-brand-logo" height="32" />
<span class="admin-brand-label">관리자</span>
<span class="text-base font-extrabold leading-tight text-slate-800">관리자</span>
</a>
<div id="auth-header-slot"></div>
</div>
</nav>
<main class="admin-main">
<header class="admin-header">
<h1>관리자</h1>
<p>회원과 저장된 덱을 관리합니다.</p>
<main class="mx-auto max-w-7xl px-4 py-8 pb-20 sm:px-6 sm:pt-16">
<header class="mb-8">
<h1 class="mb-2 text-[1.75rem] font-black tracking-tight">관리자</h1>
<p class="m-0 text-sm text-slate-500">회원과 저장된 덱을 관리합니다.</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 class="mb-7 flex gap-1 border-b border-slate-200">
<button type="button" data-tab="users" class="-mb-px cursor-pointer border-0 border-b-2 border-blue-600 bg-transparent px-[1.125rem] py-3 text-sm font-bold text-blue-600 transition-colors hover:text-slate-600">회원 관리</button>
<button type="button" data-tab="decks" class="-mb-px cursor-pointer border-0 border-b-2 border-transparent bg-transparent px-[1.125rem] py-3 text-sm font-bold text-slate-400 transition-colors hover:text-slate-600">덱 관리</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 class="mb-5 flex gap-3">
<input type="search" id="user-search" class="min-w-0 flex-1 rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm outline-none transition focus:border-blue-500 focus:ring-3 focus:ring-blue-500/15" placeholder="이메일, 사용자명 검색..." />
<button type="button" id="user-search-btn" class="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 id="user-list" class="admin-user-list"></div>
<div id="user-list" class="flex flex-col gap-3"></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>
<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 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 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>
<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="admin-modal-title">회원 정보</h2>
<p id="edit-modal-sub" class="admin-modal-sub"></p>
<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="admin-modal-avatar-remove hidden">프로필 사진 제거</button>
<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="admin-modal-content">
<div class="px-6 pb-6 pt-5">
<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 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="admin-field">
<label class="admin-label" for="edit-username">사용자명</label>
<input type="text" id="edit-username" class="admin-input" autocomplete="off" />
<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="admin-field">
<label class="admin-label" for="edit-role">역할</label>
<select id="edit-role" class="admin-select">
<div class="mb-4">
<label class="mb-1.5 block text-[0.6875rem] font-bold uppercase text-slate-400" for="edit-role">역할</label>
<select id="edit-role" 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="user">일반 회원</option>
<option value="admin">관리자</option>
</select>
<p id="edit-role-hint" class="admin-field-hint warn hidden">본인 계정의 관리자 권한은 변경할 수 없습니다.</p>
<p id="edit-role-hint" class="mt-1.5 hidden text-[0.6875rem] leading-snug text-amber-600">본인 계정의 관리자 권한은 변경할 수 없습니다.</p>
</div>
<div class="admin-field">
<label class="admin-label" for="edit-isActive">상태</label>
<select id="edit-isActive" class="admin-select">
<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="admin-field-hint warn hidden">본인 계정은 비활성화할 수 없습니다.</p>
<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="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>
<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="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 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="admin-modal-content">
<div class="px-6 pb-6 pt-5">
<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 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="admin-field">
<label class="admin-label" for="password-confirm">비밀번호 확인</label>
<input type="password" id="password-confirm" class="admin-input" placeholder="다시 입력" autocomplete="new-password" />
<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="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>
<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>
@@ -136,10 +156,21 @@
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 = [];
function escapeHtml(str) {
return String(str ?? '')
@@ -184,23 +215,23 @@
const { users, total } = await UaDeckApi.adminGetUsers(q);
const list = document.getElementById('user-list');
list.innerHTML =
`<p class="admin-count">총 ${total}명</p>` +
`<p class="mb-4 text-xs text-slate-400">총 ${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>
<article class="rounded-2xl border border-slate-200 bg-white px-[1.125rem] py-4 shadow-sm">
<div class="flex items-center gap-4">
<img src="${escapeHtml(resolveAvatarSrc(u))}" alt="" class="h-12 w-12 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-1 text-[0.9375rem] font-bold leading-snug text-slate-900">${escapeHtml(u.username)}${isSelf ? ' <span class="rounded-md bg-blue-50 px-2 py-0.5 text-[0.625rem] font-bold text-blue-600">나</span>' : ''}</h3>
<div class="text-xs leading-normal text-slate-400">${escapeHtml(u.email)}</div>
<div class="mt-2 flex flex-wrap gap-1.5">
<span class="rounded-md bg-blue-50 px-2 py-0.5 text-[0.625rem] font-bold text-blue-600">${escapeHtml(u.role)}</span>
<span class="rounded-md px-2 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>
<button type="button" data-edit='${JSON.stringify(u).replace(/'/g, '&#39;')}' class="admin-card-btn admin-card-btn-edit">수정</button>
<button type="button" data-edit='${JSON.stringify(u).replace(/'/g, '&#39;')}' class="shrink-0 cursor-pointer rounded-lg border-0 bg-slate-100 px-3 py-1.5 text-[0.6875rem] font-bold text-slate-600 hover:bg-slate-200">수정</button>
</div>
</article>`;
})
@@ -233,52 +264,152 @@
document.getElementById('edit-status-hint').classList.toggle('hidden', !isSelf);
document.getElementById('modal-error').classList.add('hidden');
editModal.classList.add('open');
setModalOpen(editModal, true);
}
function renderDeckPreview(d) {
if (!d.preview_url) {
return '<div class="admin-deck-preview-placeholder">미리보기 없음</div>';
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" onerror="this.parentElement.innerHTML='<div class=\\'admin-deck-preview-placeholder\\'>미리보기 없음</div>'" />`;
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>'" />`;
}
async function loadAdminDecks() {
const { decks } = await UaDeckApi.adminGetDecks();
document.getElementById('deck-count').textContent = `총 ${decks.length}개`;
document.getElementById('admin-deck-list').innerHTML = decks
/**
* 바이트 크기 표시
* @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 publicBadge = d.isPublic
? '<span class="admin-badge admin-badge-public">공개</span>'
: '<span class="admin-badge admin-badge-private">비공개</span>';
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="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}
<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>
<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>
<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('');
.join('')}</div>`;
closeAdminDeckMenus();
}
document.querySelectorAll('[data-del]').forEach((btn) => {
btn.addEventListener('click', async () => {
if (!confirm('삭제하시겠습니까?')) return;
await UaDeckApi.adminDeleteDeck(btn.dataset.del);
loadAdminDecks();
});
});
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', () => {
@@ -313,11 +444,11 @@
document.getElementById('password-new').value = '';
document.getElementById('password-confirm').value = '';
document.getElementById('password-error').classList.add('hidden');
passwordModal.classList.add('open');
setModalOpen(passwordModal, true);
});
document.getElementById('password-cancel').addEventListener('click', () => {
passwordModal.classList.remove('open');
setModalOpen(passwordModal, false);
});
document.getElementById('password-save').addEventListener('click', async () => {
@@ -337,7 +468,7 @@
}
try {
await UaDeckApi.adminResetPassword(document.getElementById('password-user-id').value, pw);
passwordModal.classList.remove('open');
setModalOpen(passwordModal, false);
alert('비밀번호가 변경되었습니다.');
} catch (err) {
errEl.textContent = err.message;
@@ -345,21 +476,63 @@
}
});
document.querySelectorAll('.admin-tab').forEach((btn) => {
document.querySelectorAll('[data-tab]').forEach((btn) => {
btn.addEventListener('click', () => {
document.querySelectorAll('.admin-tab').forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
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);
});
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', () => {
editModal.classList.remove('open');
setModalOpen(editModal, false);
});
document.getElementById('modal-save').addEventListener('click', async () => {
@@ -380,7 +553,7 @@
try {
await UaDeckApi.adminUpdateUser(id, body);
editModal.classList.remove('open');
setModalOpen(editModal, false);
loadUsers(document.getElementById('user-search').value);
if (isSelf && window.UaDeckAuth?.refresh) {
await window.UaDeckAuth.refresh();
@@ -393,7 +566,7 @@
[editModal, passwordModal].forEach((modal) => {
modal.addEventListener('click', (e) => {
if (e.target === modal) modal.classList.remove('open');
if (e.target === modal) setModalOpen(modal, false);
});
});
@@ -401,6 +574,7 @@
if (await guardAdmin()) {
loadUsers();
loadAdminDecks();
loadOrphanPreviews();
}
});
</script>