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

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

@@ -1 +1 @@
v4.0.1
v4.0.3

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>
<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 class="admin-card-actions" style="margin-top:0.5rem">
<button type="button" data-del="${escapeHtml(d.shareCode)}" class="admin-card-btn admin-card-btn-delete">삭제</button>
</div>
</div>
</article>`;
})
.join('');
.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>

View File

@@ -1,6 +1,6 @@
{
"name": "uadeck-api",
"version": "4.0.1",
"version": "4.0.3",
"type": "module",
"main": "src/index.js",
"scripts": {

View File

@@ -52,6 +52,17 @@ export function initDb(dbPath) {
CREATE INDEX IF NOT EXISTS idx_decks_user_id ON decks(user_id);
CREATE INDEX IF NOT EXISTS idx_decks_content_hash ON decks(content_hash);
CREATE TABLE IF NOT EXISTS deck_favorites (
user_id INTEGER NOT NULL,
share_code TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, share_code),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (share_code) REFERENCES decks(share_code) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_deck_favorites_share_code ON deck_favorites(share_code);
`);
const deckCols = db.prepare('PRAGMA table_info(decks)').all();

View File

@@ -29,7 +29,7 @@ app.use(cors({ origin: corsOrigins, credentials: true }));
app.use(express.json({ limit: '20mb' }));
app.get('/api/v1/health', (_req, res) => {
res.json({ ok: true, version: '4.0.1' });
res.json({ ok: true, version: '4.0.3' });
});
app.use('/api/v1/auth', createAuthRouter(db));
@@ -47,7 +47,7 @@ app.use((err, _req, res, _next) => {
});
app.listen(PORT, () => {
console.log(`UNIARE API v4.0.1 listening on :${PORT}`);
console.log(`UNIARE API v4.0.3 listening on :${PORT}`);
console.log(`DB: ${DB_PATH}`);
console.log(`DATAS: ${process.env.DATAS_PATH || '(default ../datas)'}`);
});

View File

@@ -3,7 +3,12 @@ import bcrypt from 'bcryptjs';
import { createRequireAdmin } from '../middleware/auth.js';
import { contentHash, validateCards, nowIso } from '../utils.js';
import { resolveDeckCards } from '../services/cardResolver.js';
import { getPreviewUrl } from '../services/previewImage.js';
import {
getPreviewUrl,
deletePreviewImage,
listOrphanPreviewImages,
deleteOrphanPreviewImages,
} from '../services/previewImage.js';
import { getAvatarUrl, saveAvatarImage, deleteAvatarImage } from '../services/avatarImage.js';
/**
@@ -237,13 +242,29 @@ export function createAdminRouter(db) {
});
});
router.get('/previews/orphans', (_req, res) => {
const referencedCodes = db.prepare('SELECT share_code FROM decks').all().map((row) => row.share_code);
const files = listOrphanPreviewImages(referencedCodes);
res.json({
files,
totalCount: files.length,
totalBytes: files.reduce((sum, file) => sum + file.size, 0),
});
});
router.delete('/previews/orphans', (_req, res) => {
const referencedCodes = db.prepare('SELECT share_code FROM decks').all().map((row) => row.share_code);
res.json({ ok: true, ...deleteOrphanPreviewImages(referencedCodes) });
});
router.delete('/decks/:shareCode', (req, res) => {
const row = db.prepare('SELECT id FROM decks WHERE share_code = ?').get(req.params.shareCode);
const row = db.prepare('SELECT id, share_code FROM decks WHERE share_code = ?').get(req.params.shareCode);
if (!row) {
return res.status(404).json({ error: '덱을 찾을 수 없습니다.' });
}
db.prepare('DELETE FROM decks WHERE id = ?').run(row.id);
res.json({ ok: true });
const previewDeleted = deletePreviewImage(row.share_code);
res.json({ ok: true, previewDeleted });
});
return router;

View File

@@ -3,7 +3,12 @@ import path from 'path';
import { requireAuth, optionalAuth } from '../middleware/auth.js';
import { resolveDeckCards } from '../services/cardResolver.js';
import { generateShareCode, validateCards, nowIso } from '../utils.js';
import { savePreviewImage, getPreviewFilePath, getPreviewUrl } from '../services/previewImage.js';
import {
savePreviewImage,
getPreviewFilePath,
getPreviewUrl,
deletePreviewImage,
} from '../services/previewImage.js';
/**
* 덱 행을 API 응답 형식으로 변환
@@ -57,6 +62,84 @@ function getAuthor(db, userId) {
export function createDecksRouter(db) {
const router = Router();
/**
* 현재 사용자가 접근할 수 없는 즐겨찾기 정리
* @param {number} userId
*/
function cleanupUnavailableFavorites(userId) {
db.prepare(`
DELETE FROM deck_favorites
WHERE user_id = ?
AND share_code IN (
SELECT f.share_code
FROM deck_favorites f
JOIN decks d ON d.share_code = f.share_code
WHERE f.user_id = ?
AND (
(d.expires_at IS NOT NULL AND d.expires_at <= datetime('now'))
OR (d.is_public = 0 AND (d.user_id IS NULL OR d.user_id != ?))
)
)
`).run(userId, userId, userId);
}
router.get('/favorites', requireAuth, (req, res) => {
cleanupUnavailableFavorites(req.user.id);
const favorites = db.prepare(`
SELECT share_code
FROM deck_favorites
WHERE user_id = ?
ORDER BY created_at DESC
`).all(req.user.id);
res.json({ favorites: favorites.map((row) => row.share_code) });
});
router.post('/favorites', requireAuth, (req, res) => {
const shareCodes = Array.isArray(req.body?.shareCodes)
? [...new Set(req.body.shareCodes.filter((code) => typeof code === 'string' && code.length <= 64))].slice(0, 500)
: [];
const insert = db.prepare(`
INSERT OR IGNORE INTO deck_favorites (user_id, share_code)
SELECT ?, share_code
FROM decks
WHERE share_code = ?
AND (expires_at IS NULL OR expires_at > datetime('now'))
AND (is_public = 1 OR user_id = ?)
`);
const sync = db.transaction((codes) => {
codes.forEach((code) => insert.run(req.user.id, code, req.user.id));
});
sync(shareCodes);
cleanupUnavailableFavorites(req.user.id);
const favorites = db.prepare(`
SELECT share_code FROM deck_favorites WHERE user_id = ? ORDER BY created_at DESC
`).all(req.user.id);
res.json({ favorites: favorites.map((row) => row.share_code) });
});
router.put('/favorites/:shareCode', requireAuth, (req, res) => {
const deck = db.prepare(`
SELECT share_code
FROM decks
WHERE share_code = ?
AND (expires_at IS NULL OR expires_at > datetime('now'))
AND (is_public = 1 OR user_id = ?)
`).get(req.params.shareCode, req.user.id);
if (!deck) {
return res.status(404).json({ error: '즐겨찾기에 추가할 수 있는 덱을 찾을 수 없습니다.' });
}
db.prepare(`
INSERT OR IGNORE INTO deck_favorites (user_id, share_code) VALUES (?, ?)
`).run(req.user.id, deck.share_code);
res.json({ ok: true, favorite: true });
});
router.delete('/favorites/:shareCode', requireAuth, (req, res) => {
db.prepare('DELETE FROM deck_favorites WHERE user_id = ? AND share_code = ?')
.run(req.user.id, req.params.shareCode);
res.json({ ok: true, favorite: false });
});
router.get('/presets', async (_req, res, next) => {
try {
const rows = db.prepare(`
@@ -283,7 +366,8 @@ export function createDecksRouter(db) {
}
db.prepare('DELETE FROM decks WHERE id = ?').run(row.id);
res.json({ ok: true });
const previewDeleted = deletePreviewImage(row.share_code);
res.json({ ok: true, previewDeleted });
});
return router;

View File

@@ -53,3 +53,59 @@ export function getPreviewUrl(shareCode, previewImageRecord = null) {
}
return null;
}
/**
* 공유 코드에 연결된 미리보기 파일 삭제
* @param {string} shareCode
* @returns {boolean}
*/
export function deletePreviewImage(shareCode) {
const filePath = path.join(getPreviewsDir(), `${shareCode}.png`);
if (!fs.existsSync(filePath)) return false;
try {
fs.unlinkSync(filePath);
return true;
} catch (err) {
console.warn(`[preview] 삭제 실패: ${filePath}`, err);
return false;
}
}
/**
* DB에서 참조하지 않는 미리보기 파일 목록
* @param {Iterable<string>} referencedShareCodes
* @returns {Array<{fileName: string, size: number, modifiedAt: string}>}
*/
export function listOrphanPreviewImages(referencedShareCodes) {
const referenced = new Set([...referencedShareCodes].map((code) => `${code}.png`));
return fs
.readdirSync(getPreviewsDir(), { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.png'))
.filter((entry) => !referenced.has(entry.name))
.map((entry) => {
const stat = fs.statSync(path.join(getPreviewsDir(), entry.name));
return {
fileName: entry.name,
size: stat.size,
modifiedAt: stat.mtime.toISOString(),
};
})
.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
}
/**
* 미참조 미리보기 파일 일괄 삭제
* @param {Iterable<string>} referencedShareCodes
* @returns {{deletedCount: number, deletedBytes: number}}
*/
export function deleteOrphanPreviewImages(referencedShareCodes) {
const orphans = listOrphanPreviewImages(referencedShareCodes);
let deletedBytes = 0;
orphans.forEach((file) => {
fs.unlinkSync(path.join(getPreviewsDir(), file.fileName));
deletedBytes += file.size;
});
return { deletedCount: orphans.length, deletedBytes };
}

View File

@@ -387,6 +387,7 @@
if (window.UaPageI18n?.applyPageI18n) {
await window.UaPageI18n.applyPageI18n();
}
await UaDeckFavorites.init();
loadDecks();
document.getElementById('deck-search').addEventListener('input', renderDeckList);
@@ -421,8 +422,12 @@
const favBtn = e.target.closest('[data-favorite]');
if (favBtn) {
UaDeckFavorites.toggle(favBtn.dataset.favorite);
try {
await UaDeckFavorites.toggle(favBtn.dataset.favorite);
renderDeckList();
} catch (err) {
alert(err.message || t('deckFavoriteFailed', '즐겨찾기 변경에 실패했습니다.'));
}
return;
}
@@ -492,6 +497,7 @@
closeAllDeckMenus();
if (!confirm(t('deckDeleteConfirm', '이 덱을 삭제하시겠습니까?'))) return;
await UaDeckApi.deleteDeck(deleteBtn.dataset.delete);
UaDeckFavorites.forget(deleteBtn.dataset.delete);
allDecks = allDecks.filter((d) => d.share_code !== deleteBtn.dataset.delete);
if (!allDecks.length) {
document.getElementById('deck-toolbar').classList.add('hidden');

View File

@@ -1,5 +1,23 @@
# 변경사항
## v4.0.3
### 즐겨찾기 계정 동기화
로그인한 사용자의 즐겨찾기가 계정에 저장됩니다. 다른 브라우저나 기기에서도 같은 계정으로 로그인하면 즐겨찾기가 유지됩니다.
기존 브라우저에 저장된 즐겨찾기는 로그인 시 계정으로 이전되며, 삭제되거나 더 이상 공개되지 않는 덱은 자동으로 정리됩니다.
## v4.0.2
### 관리자 덱 관리 화면 개선
관리자 화면에서도 내 덱과 동일한 형태로 시리즈, 공개 상태, 수정일을 확인할 수 있습니다. 삭제 기능은 각 카드의 더보기 메뉴로 이동했습니다.
### 불필요한 덱 썸네일 정리
덱을 삭제하면 연결된 썸네일도 함께 삭제됩니다. 기존에 남은 미참조 썸네일은 관리자 화면에서 목록과 용량을 확인한 뒤 정리할 수 있습니다.
## v4.0.1
### 프로젝트 운영 문서 정비

View File

@@ -31,7 +31,7 @@
## 스타일
- 현재 CSS를 Tailwind로 일괄 전환하지 않는다.
- 기존 CSS는 `todo.md`의 화면별 순서에 따라 한 화면씩 Tailwind로 전환한다.
- 새 단순 스타일은 Tailwind 유틸리티를 우선 검토하되 기존 페이지 패턴과 빌드 여부를 함께 확인한다.
- 반복되는 공통 스타일은 공통 CSS 또는 컴포넌트 역할의 클래스로 분리한다.
- `style/output.css`를 직접 편집하지 않고 `npm run build:css`로 생성한다.

View File

@@ -67,7 +67,7 @@ Docker 실행: `docker compose -f docker-compose.prod.yml up -d --build`
API 확인: `curl -fsS http://localhost:44082/api/v1/health`
정상이면 `{"ok":true,"version":"4.0.1"}`이 표시된다.
정상이면 `{"ok":true,"version":"4.0.3"}`이 표시된다.
NAS 리버스 프록시는 `https://uniare.sori.studio`을 `http://<NAS_IP>:44082`로 연결한다.

View File

@@ -1,5 +1,35 @@
# 의사결정 이력
## 2026-06-23 v4.0.3 — 검색 설명은 정적 기본 콘텐츠를 제공
💡 JavaScript 번역 로딩 전 빈 문구나 네트워크 오류가 검색 결과 설명으로 수집되는 것을 막기 위함.
🔧 메인 제목·설명 기본값과 SEO 메타데이터를 HTML에 기록하고 내부 오류 메시지는 사용자 안내로 대체.
## 2026-06-23 v4.0.3 — Tailwind 전환은 화면 단위로 진행
💡 여러 화면의 CSS를 동시에 바꾸면 시각 회귀 범위와 검수 부담이 지나치게 커지기 때문.
🔧 관리자와 인증 화면부터 전환하고 전용 CSS는 제거하며 공용 덱 카드 CSS는 해당 화면 차례까지 유지.
## 2026-06-23 v4.0.3 — 로그인 즐겨찾기는 계정 DB에 저장
💡 브라우저를 바꾸거나 데이터를 지워도 사용자의 즐겨찾기가 유지되어야 하기 때문.
🔧 계정별 `deck_favorites`를 추가하고 기존 로컬 항목은 유효한 덱만 최초 로그인 시 이전.
## 2026-06-23 v4.0.3 — 접근할 수 없는 즐겨찾기는 자동 정리
💡 삭제·만료·비공개 전환된 덱이 즐겨찾기 목록에 남아 빈 항목을 만들지 않게 하기 위함.
🔧 덱 삭제는 외래키 연쇄 삭제, 만료·타인 비공개 덱은 즐겨찾기 조회 시 제거.
## 2026-06-23 v4.0.2 — 덱 삭제와 미리보기 파일 수명주기를 연결
💡 DB에서 덱만 삭제하면 더 이상 참조되지 않는 PNG가 운영 스토리지에 계속 누적되기 때문.
🔧 덱 삭제 시 연결 PNG를 함께 지우고, 관리자는 과거 고아 파일을 조회·일괄 정리할 수 있게 구성.
## 2026-06-23 v4.0.2 — 관리자 덱 목록도 사용자 덱 카드 구조를 사용
💡 같은 덱 정보를 화면마다 다르게 표시하면 관리자가 시리즈·공개 상태·수정일을 비교하기 어렵기 때문.
🔧 사용자 덱과 같은 카드·배지·날짜 구조를 적용하고 관리자 삭제는 더보기 메뉴로 이동.
## 2026-06-23 v4.0.0 — SQLite 백업은 온라인 백업 API를 사용
💡 WAL 모드에서 DB 본파일만 복사하면 아직 병합되지 않은 최신 트랜잭션이 누락될 수 있기 때문.

View File

@@ -66,6 +66,10 @@ script/deck-prefs.js
덱 즐겨찾기
script/deck-favorites.js
계정 즐겨찾기 DB·API
api/src/db.js
api/src/routes/decks.js
덱 썸네일 확대
script/deck-preview-hold.js
@@ -79,9 +83,6 @@ script/ads.js
## 페이지 전용 스타일
```text
로그인·회원가입
style/auth-pages.css
프로필
style/profile.css
@@ -89,7 +90,11 @@ style/profile.css
style/deck-history.css
관리자
style/admin.css
admin.html — 관리자 화면 Tailwind 유틸리티
로그인·회원가입
login.html
signup.html
Tailwind 입력·산출물
style/input.css
@@ -125,6 +130,10 @@ api/src/services/avatarImage.js
덱 미리보기 저장
api/src/services/previewImage.js
미참조 덱 미리보기 조회·정리
api/src/routes/admin.js
api/src/services/previewImage.js
```
## 데이터·자산

View File

@@ -3,7 +3,7 @@
## 프로젝트 개요
- 서비스명: `UNIARE - Union Arena Deck Builder`
- 현재 버전: `v4.0.1`
- 현재 버전: `v4.0.3`
- 프론트: 정적 HTML, CSS, JavaScript
- 백엔드: Express, SQLite(`better-sqlite3`)
- 운영: Docker Compose 기반 Nginx + API
@@ -26,6 +26,14 @@
| `/admin.html` | 회원과 덱 관리 |
| `/d/<shareCode>` | Nginx에서 `cardList.html?d=<shareCode>`로 연결 |
## 검색 노출
- 메인 검색 제목은 `UNIARE | 유니온 아레나 덱 빌더`를 사용한다.
- 메인 설명은 카드 검색, 덱 제작·저장·공유, 공개 덱과 즐겨찾기 기능을 안내한다.
- JavaScript 실행 전에도 메인 제목과 설명이 HTML에 표시된다.
- canonical, Open Graph, Twitter Card, WebSite·WebApplication 구조화 데이터를 제공한다.
- 번역 파일 로딩 실패 시 내부 오류 대신 새로고침 안내를 표시한다.
## 카드 데이터
- 데이터 위치: `datas/*.js`
@@ -60,6 +68,8 @@
- `images/**`는 Git LFS로 관리한다.
- 아바타와 덱 미리보기는 API 데이터 디렉터리에 저장하며 Git에 포함하지 않는다.
- 덱 저장 미리보기는 1200px 기준 PNG로 정규화한다.
- 덱 삭제 시 해당 공유 코드의 미리보기 PNG도 함께 삭제한다.
- 관리자는 DB에서 참조하지 않는 미리보기 파일을 조회하고 일괄 삭제할 수 있다.
## 브라우저 상태
@@ -70,7 +80,7 @@
| `selectedSeries` | 현재 시리즈 |
| `uadeck_token` | JWT |
| `uadeck_user` | 로그인 사용자 캐시 |
| 덱 즐겨찾기 키 | 공유 코드 기반 로컬 즐겨찾기 |
| `uadeck.deckFavorites` | 비로그인 즐겨찾기 및 로그인 시 최초 이전 대상 |
## 인증 및 권한
@@ -80,6 +90,8 @@
- `admin`은 회원 상태·역할·비밀번호와 전체 덱을 관리할 수 있다.
- 관리자는 자신의 관리자 역할을 해제하거나 자신의 계정을 비활성화할 수 없다.
- 비공개 덱은 소유자와 관리자만 조회할 수 있다.
- 로그인 사용자의 즐겨찾기는 계정 DB에 저장되어 브라우저와 기기가 달라도 유지된다.
- 비로그인 즐겨찾기는 브라우저에 저장하고 로그인 시 유효한 덱만 계정으로 이전한다.
## 덱 저장 정책
@@ -125,6 +137,14 @@
| `preview_image` | 저장된 미리보기 파일 정보 |
| `created_at`, `updated_at` | 생성·수정 시각 |
### `deck_favorites`
| 컬럼 | 설명 |
|---|---|
| `user_id` | 즐겨찾기 사용자, 사용자 삭제 시 함께 삭제 |
| `share_code` | 즐겨찾기 덱, 덱 삭제 시 함께 삭제 |
| `created_at` | 즐겨찾기 등록 시각 |
## API
### 공통
@@ -155,6 +175,10 @@
| POST | `/api/v1/decks` | 공개 | 회원 또는 비회원 덱 저장 |
| PUT | `/api/v1/decks/:shareCode` | 소유자·관리자 | 덱 수정 |
| DELETE | `/api/v1/decks/:shareCode` | 소유자·관리자 | 덱 삭제 |
| GET | `/api/v1/decks/favorites` | 회원 | 내 즐겨찾기 코드 목록 |
| POST | `/api/v1/decks/favorites` | 회원 | 브라우저 즐겨찾기 일괄 이전 |
| PUT | `/api/v1/decks/favorites/:shareCode` | 회원 | 즐겨찾기 추가 |
| DELETE | `/api/v1/decks/favorites/:shareCode` | 회원 | 즐겨찾기 제거 |
### 관리자
@@ -166,12 +190,17 @@
| POST | `/api/v1/admin/users/:id/reset-password` | 비밀번호 초기화 |
| GET | `/api/v1/admin/decks` | 전체 덱 목록 |
| DELETE | `/api/v1/admin/decks/:shareCode` | 덱 삭제 |
| GET | `/api/v1/admin/previews/orphans` | 미참조 덱 미리보기 목록 |
| DELETE | `/api/v1/admin/previews/orphans` | 미참조 덱 미리보기 일괄 삭제 |
| POST | `/api/v1/admin/decks/preset` | 레거시 프리셋 생성 |
## 스타일
- 현재는 Tailwind 유틸리티, 공통 CSS, 페이지 전용 CSS, 일부 인라인 스타일을 혼용한다.
- 이번 단계에서는 기존 CSS를 Tailwind로 일괄 전환하지 않는다.
- 기존 CSS는 `todo.md`의 화면별 체크리스트 순서에 따라 점진적으로 Tailwind로 전환한다.
- 관리자와 로그인·회원가입 화면은 Tailwind 전환을 완료하고 전용 CSS를 제거했다.
- 관리자 탭·모달 상태는 JavaScript에서 `border-*`, `text-*`, `hidden`, `flex` 유틸리티를 전환한다.
- 관리자 덱 목록이 재사용하는 `style/deck-history.css`는 내 덱·공개 덱 화면 전환 때 함께 정리한다.
- `style/output.css`는 `style/input.css`에서 생성하는 빌드 산출물이다.
- 헤더 로고 크기는 `style/brand.css`만 기준으로 사용한다.
- 기본 글꼴은 Pretendard 계열을 우선 사용한다.
@@ -180,6 +209,5 @@
- `cardList.html`, `result.html`의 인라인 스크립트가 커서 변경 영향 범위가 넓다.
- `admin.html`은 3개 언어 번역 적용이 완료되지 않았다.
- 즐겨찾기는 브라우저별 로컬 상태라 계정 간 동기화되지 않는다.
- Homepage 전용 `GET /api/homepage` 엔드포인트는 아직 구현되지 않았다.
- 현재 Compose의 컨테이너명(`uniare-api`, `uniare-web`)과 데이터 볼륨명(`uniare-data`)은 고정값이며 환경 변수화되지 않았다.

View File

@@ -3,6 +3,7 @@
## 운영
- NAS에서 `uniare-web`, `uniare-api` 스택의 재기동과 헬스 체크를 최종 확인한다.
- SEO 메타데이터 배포 후 Google Search Console에서 메인 URL 재색인을 요청한다.
- 운영 `.env`의 `JWT_SECRET`, 관리자 계정, CORS 허용 도메인을 실제 환경 기준으로 점검한다.
- DB와 업로드 이미지(`api/data/previews`, `api/data/avatars`)의 정기 백업·복구 절차를 검증한다.
- 원격 저장소의 Git LFS 업로드 상태와 용량 정책을 확인한다.
@@ -18,5 +19,16 @@
- `cardList.html`, `result.html`의 대형 인라인 스크립트를 기능 단위 모듈로 분리한다.
- `datas/ua99 sample.js`, `datas/ua99 sample mini.js`의 보존 목적과 배포 제외 여부를 확정한다.
- 즐겨찾기의 계정 간 동기화가 필요하면 사용자별 API 저장 방식으로 확장한다.
- 이미지 규모가 Git LFS 운영 한도를 넘으면 자산 저장소 또는 CDN 분리를 검토한다.
## 화면별 Tailwind 전환
- [x] 관리자 (`admin.html`, 전용 CSS 제거)
- [x] 로그인·회원가입 (`login.html`, `signup.html`, 전용 CSS 제거)
- [ ] 프로필 (`profile.html`, `style/profile.css`)
- [ ] 내 덱 (`deckHistory.html`, `style/deck-history.css`)
- [ ] 공개 덱 (`preset.html`, `style/deck-history.css`)
- [ ] 공통 인증 헤더 (`script/auth-header.js`, `style/auth-header.css`)
- [ ] 공통 앱 셸 (`style/app-shell.css`)
- [ ] 카드 목록 (`cardList.html`, `style/style.css`)
- [ ] 덱 결과 (`result.html`)
- [ ] 메인·공통 내비게이션 (`index.html`, `style/navigation.css`)

View File

@@ -1,5 +1,33 @@
# 업데이트 이력
## 2026-06-23 / v4.0.3 — 계정 즐겨찾기 동기화
### 수행 작업
- 로그인 사용자의 즐겨찾기를 `deck_favorites` DB 테이블에 계정별 저장
- 다른 브라우저와 기기에서도 같은 계정의 즐겨찾기 유지
- 기존 브라우저 즐겨찾기는 로그인 시 접근 가능한 덱만 계정으로 자동 이전
- 덱 삭제 시 외래키로 관련 즐겨찾기 자동 삭제
- 만료되거나 타인이 비공개로 전환한 덱은 즐겨찾기 조회 시 자동 정리
- 비로그인 사용자는 기존처럼 브라우저 `localStorage` 즐겨찾기 사용
- 개인 NAS·자체 Git LFS 운영 구조에 불필요한 CDN 분리 TODO 제거
- `todo.md`에 화면별 Tailwind 전환 체크리스트 추가
- 관리자 레이아웃·회원 관리·모달·정리 도구를 Tailwind 유틸리티로 전환
- 관리자 탭·모달 상태도 JavaScript에서 Tailwind 클래스를 전환하도록 변경하고 `style/admin.css` 제거
- 로그인·회원가입 화면을 Tailwind 유틸리티로 전환하고 `style/auth-pages.css` 제거
- Google Fonts 외부 호출을 제거하고 Pretendard 우선 시스템 폰트 스택 적용
- `style/output.css` 재빌드
- 메인 페이지 검색 제목·설명을 UNIARE 기준으로 변경하고 canonical·Open Graph·Twitter·구조화 데이터 추가
- 검색 엔진이 빈 제목이나 `Failed to fetch` 오류를 수집하지 않도록 정적 기본 문구와 사용자용 오류 안내 적용
## 2026-06-23 / v4.0.2 — 관리자 덱 관리·썸네일 정리 개선
### 수행 작업
- 관리자 화면 본문에 반응형 좌우 여백을 적용해 좁은 화면에서 콘텐츠가 벽에 붙는 문제 수정
- 관리자 덱 목록을 내 덱 화면과 같은 시리즈·공개 상태·수정일 카드 구조로 변경
- 조회 수와 `/d/공유코드` 표시 제거, 삭제 기능을 더보기 메뉴로 이동
- 일반 사용자와 관리자 덱 삭제 API에서 연결된 미리보기 PNG도 함께 삭제
- 관리자가 미참조 썸네일의 파일명·용량·수정일을 확인하고 일괄 삭제하는 기능 추가
## 2026-06-23 / v4.0.1 — 프로젝트 문서 체계 정비
### 수행 작업

View File

@@ -7,11 +7,18 @@
<link rel="stylesheet" href="./style/output.css" />
<link rel="stylesheet" href="./style/brand.css" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=Noto+Sans+KR:wght@400;700&display=swap" rel="stylesheet" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" type="image/png" sizes="32x32" href="/images/assets/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/images/assets/favicon-16x16.png" />
<link rel="canonical" href="https://uniare.sori.studio/" />
<script src="/script/navigation.js"></script>
<script src="/script/api.js"></script>
<link rel="stylesheet" href="./style/auth-header.css" />
<title>UNIARE - Union Arena Deck Builder</title>
<title>UNIARE | 유니온 아레나 덱 빌더</title>
<meta name="description" content="UNIARE에서 유니온 아레나 카드를 검색하고 나만의 덱을 만들어 저장·공유해 보세요. 공개 덱을 둘러보고 즐겨찾기로 관리할 수 있습니다." />
<meta name="application-name" content="UNIARE" />
<meta name="robots" content="index, follow" />
<style>
body {
@@ -49,11 +56,39 @@
}
</style>
<meta property="og:type" content="article" />
<meta property="og:title" content="유니온 아레나 덱빌더" />
<meta property="og:description" content="자신만의 덱을 만들고, 다른 플레이어들과 공유해보세요." />
<meta property="og:image" content="https://uniare.zenncloud.com/images/logo_unionarena.png" />
<meta property="og:url" content="https://uniare.zenncloud.com/" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="UNIARE" />
<meta property="og:locale" content="ko_KR" />
<meta property="og:title" content="UNIARE | 유니온 아레나 덱 빌더" />
<meta property="og:description" content="유니온 아레나 카드를 검색하고 나만의 덱을 만들어 저장·공유하세요." />
<meta property="og:image" content="https://uniare.sori.studio/images/assets/uniare-h48.png" />
<meta property="og:image:alt" content="UNIARE" />
<meta property="og:url" content="https://uniare.sori.studio/" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="UNIARE | 유니온 아레나 덱 빌더" />
<meta name="twitter:description" content="유니온 아레나 카드를 검색하고 나만의 덱을 만들어 저장·공유하세요." />
<meta name="twitter:image" content="https://uniare.sori.studio/images/assets/uniare-h48.png" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite",
"name": "UNIARE",
"alternateName": "유니온 아레나 덱 빌더",
"url": "https://uniare.sori.studio/"
},
{
"@type": "WebApplication",
"name": "UNIARE",
"url": "https://uniare.sori.studio/",
"description": "유니온 아레나 카드를 검색하고 덱을 만들어 저장·공유할 수 있는 웹 서비스",
"applicationCategory": "GameApplication",
"operatingSystem": "Web"
}
]
}
</script>
</head>
<body class="text-slate-900">
@@ -76,8 +111,8 @@
<main class="max-w-7xl mx-auto px-6 pt-12 pb-24">
<header class="text-center mb-16">
<h2 id="display-main-title" class="text-4xl sm:text-5xl font-black text-slate-900 mb-4 tracking-tight"></h2>
<p id="display-main-desc" class="text-slate-500 text-lg max-w-2xl mx-auto leading-relaxed"></p>
<h1 id="display-main-title" class="text-4xl sm:text-5xl font-black text-slate-900 mb-4 tracking-tight">Master Your Strategy</h1>
<p id="display-main-desc" class="text-slate-500 text-lg max-w-2xl mx-auto leading-relaxed">유니온 아레나 카드를 검색하고 나만의 덱을 만들어 저장·공유해 보세요.</p>
</header>
<div id="app">
@@ -153,7 +188,8 @@
console.error('Error:', error);
document.getElementById('series-container').innerHTML = `
<div class="col-span-full py-20 text-center">
<p class="text-red-500 font-bold">${error.message}</p>
<p class="text-red-500 font-bold">시리즈 정보를 불러오지 못했습니다.</p>
<p class="mt-2 text-sm text-slate-500">잠시 후 페이지를 새로고침해 주세요.</p>
</div>`;
}
}

View File

@@ -4,45 +4,44 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="./style/output.css" />
<link rel="stylesheet" href="./style/auth-pages.css" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=Noto+Sans+KR:wght@400;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="./style/brand.css" />
<title>로그인 - UNIARE</title>
</head>
<body class="auth-page">
<nav class="auth-nav">
<div class="auth-nav-inner">
<a href="/" class="auth-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 px-4 sm:px-6">
<a href="/" class="inline-flex shrink-0 items-center leading-none text-slate-800 no-underline">
<img src="/images/assets/uniare-h48.png" alt="UNIARE" class="app-brand-logo" height="32" />
</a>
</div>
</nav>
<main class="auth-main">
<header class="auth-header">
<h1 data-i18n="loginPageTitle">로그인</h1>
<p data-i18n-html="loginPageDesc">덱을 저장하고 관리하려면<br />계정으로 로그인하세요.</p>
<main class="mx-auto max-w-[28rem] px-6 py-12 sm:py-16">
<header class="mb-8 text-center">
<h1 class="mb-2 text-[1.75rem] font-black tracking-[-0.02em] text-slate-900" data-i18n="loginPageTitle">로그인</h1>
<p class="m-0 text-sm leading-normal text-slate-500" data-i18n-html="loginPageDesc">덱을 저장하고 관리하려면<br />계정으로 로그인하세요.</p>
</header>
<form id="login-form" class="auth-card">
<div class="auth-field">
<label class="auth-label" for="email" data-i18n="emailLabel">이메일</label>
<input type="email" id="email" required class="auth-input" placeholder="you@example.com" autocomplete="email" />
<form id="login-form" class="rounded-2xl border border-slate-200 bg-white p-7 shadow-[0_4px_24px_rgba(15,23,42,0.06)]">
<div class="mb-5">
<label class="mb-1.5 block text-[0.6875rem] font-bold uppercase tracking-wider text-slate-400" for="email" data-i18n="emailLabel">이메일</label>
<input type="email" id="email" required class="block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition placeholder:text-slate-300 focus:border-blue-500 focus:ring-3 focus:ring-blue-500/15" placeholder="you@example.com" autocomplete="email" />
</div>
<div class="auth-field">
<label class="auth-label" for="password" data-i18n="passwordLabel">비밀번호</label>
<input type="password" id="password" required class="auth-input" placeholder="••••••••" autocomplete="current-password" />
<div>
<label class="mb-1.5 block text-[0.6875rem] font-bold uppercase tracking-wider text-slate-400" for="password" data-i18n="passwordLabel">비밀번호</label>
<input type="password" id="password" required class="block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition placeholder:text-slate-300 focus:border-blue-500 focus:ring-3 focus:ring-blue-500/15" placeholder="••••••••" autocomplete="current-password" />
</div>
<p id="error-msg" class="auth-error hidden"></p>
<button type="submit" class="auth-submit" data-i18n="loginButton">로그인</button>
<p id="error-msg" class="mt-4 hidden text-[0.8125rem] text-red-500"></p>
<button type="submit" class="mt-6 block w-full cursor-pointer rounded-xl border-0 bg-blue-600 px-4 py-3.5 text-sm font-bold text-white transition hover:bg-blue-700 active:scale-[0.98]" data-i18n="loginButton">로그인</button>
</form>
<p class="auth-footer">
<p class="mt-6 text-center text-sm text-slate-500">
<span data-i18n="noAccount">계정이 없으신가요?</span>
<a href="/signup.html" data-i18n="authSignup">회원가입</a>
<a href="/signup.html" class="font-bold text-blue-600 hover:underline" data-i18n="authSignup">회원가입</a>
</p>
</main>
<footer class="auth-version-footer" aria-hidden="true">v4.0.1</footer>
<footer class="px-6 pb-5 text-center text-[0.625rem] font-medium tracking-[0.02em] text-slate-300" aria-hidden="true">v4.0.3</footer>
<script src="/script/page-i18n.js"></script>
<script src="/script/api.js"></script>

View File

@@ -247,6 +247,7 @@
if (window.UaPageI18n?.applyPageI18n) {
await window.UaPageI18n.applyPageI18n();
}
await UaDeckFavorites.init();
loadPublicDecks();
document.getElementById('deck-search').addEventListener('input', renderList);
document.getElementById('deck-series-filter').addEventListener('change', renderList);
@@ -260,11 +261,15 @@
renderList();
});
document.getElementById('preset-list').addEventListener('click', (e) => {
document.getElementById('preset-list').addEventListener('click', async (e) => {
const favBtn = e.target.closest('[data-favorite]');
if (favBtn) {
UaDeckFavorites.toggle(favBtn.dataset.favorite);
try {
await UaDeckFavorites.toggle(favBtn.dataset.favorite);
renderList();
} catch (err) {
alert(err.message || t('deckFavoriteFailed', '즐겨찾기 변경에 실패했습니다.'));
}
return;
}

View File

@@ -165,6 +165,23 @@ const UaDeckApi = (() => {
getPresets: () => request('/decks/presets'),
getFavorites: () => request('/decks/favorites'),
/** @param {string[]} shareCodes */
syncFavorites: (shareCodes) =>
request('/decks/favorites', {
method: 'POST',
body: JSON.stringify({ shareCodes }),
}),
/** @param {string} shareCode */
addFavorite: (shareCode) =>
request(`/decks/favorites/${shareCode}`, { method: 'PUT' }),
/** @param {string} shareCode */
removeFavorite: (shareCode) =>
request(`/decks/favorites/${shareCode}`, { method: 'DELETE' }),
/** 관리자: 회원 목록 */
adminGetUsers: (q = '', page = 1) =>
request(`/admin/users?q=${encodeURIComponent(q)}&page=${page}`),
@@ -185,6 +202,11 @@ const UaDeckApi = (() => {
adminGetDecks: () => request('/admin/decks'),
adminGetOrphanPreviews: () => request('/admin/previews/orphans'),
adminDeleteOrphanPreviews: () =>
request('/admin/previews/orphans', { method: 'DELETE' }),
/** @param {object} body */
adminCreatePreset: (body) =>
request('/admin/decks/preset', { method: 'POST', body: JSON.stringify(body) }),

View File

@@ -1,13 +1,19 @@
/**
* 덱 즐겨찾기 (localStorage, share_code 기준)
* 덱 즐겨찾기
* 로그인 사용자는 계정 DB, 비로그인 사용자는 localStorage를 사용한다.
*/
(function initDeckFavorites() {
const STORAGE_KEY = 'uadeck.deckFavorites';
/** @type {Set<string>} */
let favorites = new Set();
/** @type {Promise<void>|null} */
let initPromise = null;
/**
* 브라우저 즐겨찾기 읽기
* @returns {Set<string>}
*/
function readSet() {
function readLocalSet() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
const list = raw ? JSON.parse(raw) : [];
@@ -18,43 +24,87 @@
}
/**
* 브라우저 즐겨찾기 저장
* @param {Set<string>} set
*/
function writeSet(set) {
function writeLocalSet(set) {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...set]));
}
/**
* 즐겨찾기 초기화 및 기존 브라우저 데이터 이전
* @returns {Promise<void>}
*/
function init() {
if (initPromise) return initPromise;
initPromise = (async () => {
const localFavorites = readLocalSet();
if (!UaDeckApi.getToken()) {
favorites = localFavorites;
return;
}
try {
const data = localFavorites.size
? await UaDeckApi.syncFavorites([...localFavorites])
: await UaDeckApi.getFavorites();
favorites = new Set(data.favorites || []);
localStorage.removeItem(STORAGE_KEY);
} catch (err) {
favorites = localFavorites;
console.warn('[favorites] 계정 즐겨찾기 동기화 실패', err);
}
})();
return initPromise;
}
/**
* @param {string} shareCode
* @returns {boolean}
*/
function isFavorite(shareCode) {
if (!shareCode) return false;
return readSet().has(shareCode);
return Boolean(shareCode) && favorites.has(shareCode);
}
/**
* 즐겨찾기 상태 변경
* @param {string} shareCode
* @returns {boolean} 토글 후 즐겨찾기 여부
* @returns {Promise<boolean>} 변경 후 즐겨찾기 여부
*/
function toggle(shareCode) {
async function toggle(shareCode) {
if (!shareCode) return false;
const set = readSet();
if (set.has(shareCode)) {
set.delete(shareCode);
await init();
const next = !favorites.has(shareCode);
if (UaDeckApi.getToken()) {
if (next) {
await UaDeckApi.addFavorite(shareCode);
} else {
set.add(shareCode);
await UaDeckApi.removeFavorite(shareCode);
}
writeSet(set);
return set.has(shareCode);
}
if (next) favorites.add(shareCode);
else favorites.delete(shareCode);
if (!UaDeckApi.getToken()) writeLocalSet(favorites);
return next;
}
/**
* 삭제된 덱을 현재 화면 상태에서도 제거
* @param {string} shareCode
*/
function forget(shareCode) {
favorites.delete(shareCode);
if (!UaDeckApi.getToken()) writeLocalSet(favorites);
}
/**
* @returns {string[]}
*/
function getAll() {
return [...readSet()];
return [...favorites];
}
window.UaDeckFavorites = { isFavorite, toggle, getAll };
window.UaDeckFavorites = { init, isFavorite, toggle, forget, getAll };
})();

View File

@@ -4,49 +4,48 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="./style/output.css" />
<link rel="stylesheet" href="./style/auth-pages.css" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=Noto+Sans+KR:wght@400;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="./style/brand.css" />
<title>회원가입 - UNIARE</title>
</head>
<body class="auth-page">
<nav class="auth-nav">
<div class="auth-nav-inner">
<a href="/" class="auth-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 px-4 sm:px-6">
<a href="/" class="inline-flex shrink-0 items-center leading-none text-slate-800 no-underline">
<img src="/images/assets/uniare-h48.png" alt="UNIARE" class="app-brand-logo" height="32" />
</a>
</div>
</nav>
<main class="auth-main">
<header class="auth-header">
<h1 data-i18n="signupPageTitle">회원가입</h1>
<p data-i18n-html="signupPageDesc">UNIARE 계정을 만들고<br />덱을 저장·공유해 보세요.</p>
<main class="mx-auto max-w-[28rem] px-6 py-12 sm:py-16">
<header class="mb-8 text-center">
<h1 class="mb-2 text-[1.75rem] font-black tracking-[-0.02em] text-slate-900" data-i18n="signupPageTitle">회원가입</h1>
<p class="m-0 text-sm leading-normal text-slate-500" data-i18n-html="signupPageDesc">UNIARE 계정을 만들고<br />덱을 저장·공유해 보세요.</p>
</header>
<form id="signup-form" class="auth-card">
<div class="auth-field">
<label class="auth-label" for="email" data-i18n="emailLabel">이메일</label>
<input type="email" id="email" required class="auth-input" placeholder="you@example.com" autocomplete="email" />
<form id="signup-form" class="rounded-2xl border border-slate-200 bg-white p-7 shadow-[0_4px_24px_rgba(15,23,42,0.06)]">
<div class="mb-5">
<label class="mb-1.5 block text-[0.6875rem] font-bold uppercase tracking-wider text-slate-400" for="email" data-i18n="emailLabel">이메일</label>
<input type="email" id="email" required class="block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition placeholder:text-slate-300 focus:border-blue-500 focus:ring-3 focus:ring-blue-500/15" placeholder="you@example.com" autocomplete="email" />
</div>
<div class="auth-field">
<label class="auth-label" for="username" data-i18n="usernameLabel">사용자명</label>
<input type="text" id="username" required minlength="2" class="auth-input" placeholder="deckmaster" autocomplete="username" />
<div class="mb-5">
<label class="mb-1.5 block text-[0.6875rem] font-bold uppercase tracking-wider text-slate-400" for="username" data-i18n="usernameLabel">사용자명</label>
<input type="text" id="username" required minlength="2" class="block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition placeholder:text-slate-300 focus:border-blue-500 focus:ring-3 focus:ring-blue-500/15" placeholder="deckmaster" autocomplete="username" />
</div>
<div class="auth-field">
<label class="auth-label" for="password" data-i18n="passwordLabel">비밀번호</label>
<input type="password" id="password" required minlength="8" class="auth-input" data-i18n-placeholder="signupPasswordPlaceholder" placeholder="8자 이상" autocomplete="new-password" />
<div class="mb-5">
<label class="mb-1.5 block text-[0.6875rem] font-bold uppercase tracking-wider text-slate-400" for="password" data-i18n="passwordLabel">비밀번호</label>
<input type="password" id="password" required minlength="8" class="block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition placeholder:text-slate-300 focus:border-blue-500 focus:ring-3 focus:ring-blue-500/15" data-i18n-placeholder="signupPasswordPlaceholder" placeholder="8자 이상" autocomplete="new-password" />
</div>
<div class="auth-field">
<label class="auth-label" for="password-confirm" data-i18n="passwordConfirmLabel">비밀번호 확인</label>
<input type="password" id="password-confirm" required minlength="8" class="auth-input" data-i18n-placeholder="passwordConfirmPlaceholder" placeholder="비밀번호 재입력" autocomplete="new-password" />
<div>
<label class="mb-1.5 block text-[0.6875rem] font-bold uppercase tracking-wider text-slate-400" for="password-confirm" data-i18n="passwordConfirmLabel">비밀번호 확인</label>
<input type="password" id="password-confirm" required minlength="8" class="block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition placeholder:text-slate-300 focus:border-blue-500 focus:ring-3 focus:ring-blue-500/15" data-i18n-placeholder="passwordConfirmPlaceholder" placeholder="비밀번호 재입력" autocomplete="new-password" />
</div>
<p id="error-msg" class="auth-error hidden"></p>
<button type="submit" class="auth-submit" data-i18n="signupButton">가입하기</button>
<p id="error-msg" class="mt-4 hidden text-[0.8125rem] text-red-500"></p>
<button type="submit" class="mt-6 block w-full cursor-pointer rounded-xl border-0 bg-blue-600 px-4 py-3.5 text-sm font-bold text-white transition hover:bg-blue-700 active:scale-[0.98]" data-i18n="signupButton">가입하기</button>
</form>
<p class="auth-footer">
<p class="mt-6 text-center text-sm text-slate-500">
<span data-i18n="hasAccount">이미 계정이 있으신가요?</span>
<a href="/login.html" data-i18n="authLogin">로그인</a>
<a href="/login.html" class="font-bold text-blue-600 hover:underline" data-i18n="authLogin">로그인</a>
</p>
</main>

View File

@@ -1,520 +0,0 @@
/**
* 관리자 페이지 (admin.html)
*/
@import './brand.css';
.admin-page {
font-family: 'Inter', 'Noto Sans KR', sans-serif;
min-height: 100vh;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
color: #0f172a;
}
.admin-nav {
position: sticky;
top: 0;
z-index: 50;
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(12px);
border-bottom: 1px solid #e2e8f0;
}
.admin-nav-inner {
max-width: 80rem;
margin: 0 auto;
padding: 0 1.5rem;
height: 4rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.admin-brand {
gap: 0.625rem;
letter-spacing: -0.025em;
}
.admin-brand-label {
font-size: 1rem;
font-weight: 800;
color: #1e293b;
line-height: 1.2;
}
.admin-main {
max-width: 80rem;
margin: 0 auto;
}
@media (min-width: 640px) {
.admin-main {
padding-top: 4rem;
}
}
.admin-header {
margin-bottom: 2rem;
}
.admin-header h1 {
font-size: 1.75rem;
font-weight: 900;
margin: 0 0 0.5rem;
letter-spacing: -0.02em;
}
.admin-header p {
margin: 0;
font-size: 0.875rem;
color: #64748b;
}
.admin-tabs {
display: flex;
gap: 0.25rem;
margin-bottom: 1.75rem;
border-bottom: 1px solid #e2e8f0;
}
.admin-tab {
padding: 0.75rem 1.125rem;
margin-bottom: -1px;
border: none;
border-bottom: 2px solid transparent;
background: none;
font-size: 0.875rem;
font-weight: 700;
color: #94a3b8;
cursor: pointer;
transition: color 0.2s, border-color 0.2s;
}
.admin-tab:hover {
color: #475569;
}
.admin-tab.active {
color: #2563eb;
border-bottom-color: #2563eb;
}
.admin-toolbar {
display: flex;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.admin-search {
flex: 1;
border: 1px solid #e2e8f0;
border-radius: 0.75rem;
padding: 0.625rem 1rem;
font-size: 0.875rem;
background: #fff;
outline: none;
}
.admin-search:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
.admin-btn {
padding: 0.625rem 1rem;
border: none;
border-radius: 0.75rem;
font-size: 0.8125rem;
font-weight: 700;
cursor: pointer;
}
.admin-btn-primary {
background: #1e293b;
color: #fff;
}
.admin-btn-secondary {
background: #f1f5f9;
color: #475569;
}
.admin-btn-secondary:hover {
background: #e2e8f0;
}
.admin-count {
font-size: 0.75rem;
color: #94a3b8;
margin-bottom: 1rem;
}
.admin-user-list,
.admin-deck-grid {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.admin-deck-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.admin-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 1rem;
padding: 1rem 1.125rem;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
}
.admin-card-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.admin-card-title {
font-size: 0.9375rem;
font-weight: 700;
color: #0f172a;
margin: 0 0 0.25rem;
line-height: 1.4;
}
.admin-card-meta {
font-size: 0.75rem;
color: #94a3b8;
line-height: 1.5;
}
.admin-card-badges {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-top: 0.5rem;
}
.admin-badge {
font-size: 0.625rem;
font-weight: 700;
padding: 0.15rem 0.45rem;
border-radius: 0.375rem;
}
.admin-badge-public {
color: #059669;
background: #ecfdf5;
}
.admin-badge-private {
color: #64748b;
background: #f1f5f9;
}
.admin-badge-type {
color: #2563eb;
background: #eff6ff;
}
.admin-card-actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.admin-card-btn {
padding: 0.4rem 0.75rem;
border: none;
border-radius: 0.5rem;
font-size: 0.6875rem;
font-weight: 700;
cursor: pointer;
}
.admin-card-btn-edit {
background: #f1f5f9;
color: #475569;
}
.admin-card-btn-edit:hover {
background: #e2e8f0;
}
.admin-card-btn-delete {
background: #fef2f2;
color: #dc2626;
}
.admin-card-btn-delete:hover {
background: #fee2e2;
}
.admin-modal {
position: fixed;
inset: 0;
z-index: 100;
display: none;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(15, 23, 42, 0.5);
}
.admin-modal.open {
display: flex;
}
.hidden {
display: none !important;
}
/* 회원 카드 */
.admin-user-card {
display: flex;
align-items: center;
gap: 1rem;
}
.admin-user-avatar {
width: 3rem;
height: 3rem;
border-radius: 9999px;
object-fit: cover;
background: #f1f5f9;
border: 2px solid #e2e8f0;
flex-shrink: 0;
}
.admin-user-body {
flex: 1;
min-width: 0;
}
/* 덱 카드 (썸네일) */
.admin-deck-card {
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
}
.admin-deck-preview {
aspect-ratio: 16 / 9;
background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%);
border-bottom: 1px solid #e2e8f0;
overflow: hidden;
}
.admin-deck-preview img {
width: 100%;
height: 100%;
object-fit: contain;
background: #f8fafc;
}
.admin-deck-preview-placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 0.6875rem;
font-weight: 600;
color: #94a3b8;
}
.admin-deck-body {
padding: 1rem 1.125rem 1.125rem;
display: flex;
flex-direction: column;
gap: 0.375rem;
flex: 1;
}
/* 회원 모달 */
.admin-modal-box {
width: 100%;
max-width: 26rem;
background: #fff;
border-radius: 1.25rem;
padding: 0;
box-shadow: 0 24px 48px rgba(15, 23, 42, 0.18);
overflow: hidden;
}
.admin-modal-header {
padding: 1.5rem 1.5rem 1rem;
background: linear-gradient(135deg, #f8fafc 0%, #eff6ff 100%);
border-bottom: 1px solid #e2e8f0;
}
.admin-modal-profile {
display: flex;
align-items: center;
gap: 1rem;
}
.admin-modal-avatar-wrap {
position: relative;
flex-shrink: 0;
}
.admin-modal-avatar {
width: 4.5rem;
height: 4.5rem;
border-radius: 9999px;
object-fit: cover;
background: #fff;
border: 3px solid #fff;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.1);
}
.admin-modal-avatar-btn {
position: absolute;
right: -0.25rem;
bottom: -0.25rem;
width: 1.75rem;
height: 1.75rem;
border-radius: 9999px;
border: 2px solid #fff;
background: #2563eb;
color: #fff;
font-size: 0.75rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.admin-modal-avatar-remove {
margin-top: 0.5rem;
font-size: 0.6875rem;
color: #94a3b8;
background: none;
border: none;
cursor: pointer;
padding: 0;
text-decoration: underline;
}
.admin-modal-title {
margin: 0;
font-size: 1.125rem;
font-weight: 800;
color: #0f172a;
}
.admin-modal-sub {
margin: 0.25rem 0 0;
font-size: 0.75rem;
color: #64748b;
}
.admin-modal-content {
padding: 1.25rem 1.5rem 1.5rem;
}
.admin-field-hint {
margin: 0.375rem 0 0;
font-size: 0.6875rem;
color: #94a3b8;
line-height: 1.4;
}
.admin-field-hint.warn {
color: #d97706;
}
.admin-input:disabled,
.admin-select:disabled {
background: #f8fafc;
color: #94a3b8;
cursor: not-allowed;
}
.admin-modal-divider {
height: 1px;
background: #e2e8f0;
margin: 1rem 0;
}
.admin-modal-actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.5rem;
}
.admin-modal-actions-row {
display: flex;
gap: 0.5rem;
}
.admin-modal-actions-row .admin-btn {
flex: 1;
}
.admin-btn-outline {
background: #fff;
color: #475569;
border: 1px solid #e2e8f0;
}
.admin-btn-outline:hover {
background: #f8fafc;
}
.admin-btn-danger-text {
background: none;
color: #dc2626;
border: none;
padding: 0.5rem;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
text-align: center;
}
.admin-btn-danger-text:hover {
text-decoration: underline;
}
.admin-field {
margin-bottom: 1rem;
}
.admin-label {
display: block;
font-size: 0.6875rem;
font-weight: 700;
color: #94a3b8;
text-transform: uppercase;
margin-bottom: 0.375rem;
}
.admin-input,
.admin-select {
width: 100%;
box-sizing: border-box;
border: 1px solid #e2e8f0;
border-radius: 0.75rem;
padding: 0.625rem 0.75rem;
font-size: 0.875rem;
}
.admin-error {
font-size: 0.8125rem;
color: #ef4444;
margin-top: 0.5rem;
}
.admin-error.hidden {
display: none;
}
.hidden {
display: none !important;
}

View File

@@ -1,178 +0,0 @@
/**
* 인증·계정 페이지 공통 레이아웃
* (Tailwind 빌드에 없는 max-w-* 유틸 대체)
*/
@import './brand.css';
.auth-page {
font-family: 'Inter', 'Noto Sans KR', sans-serif;
min-height: 100vh;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
color: #0f172a;
}
.auth-nav {
position: sticky;
top: 0;
z-index: 50;
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(12px);
border-bottom: 1px solid #e2e8f0;
}
.auth-nav-inner {
max-width: 80rem;
margin: 0 auto;
padding: 0 1.5rem;
height: 4rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.auth-brand {
color: #1e293b;
}
.auth-main {
max-width: 28rem;
margin: 0 auto;
padding: 3rem 1.5rem 4rem;
}
@media (min-width: 640px) {
.auth-main {
padding-top: 4rem;
padding-bottom: 5rem;
}
}
.auth-header {
text-align: center;
margin-bottom: 2rem;
}
.auth-header h1 {
font-size: 1.75rem;
font-weight: 900;
color: #0f172a;
margin: 0 0 0.5rem;
letter-spacing: -0.02em;
}
.auth-header p {
font-size: 0.875rem;
color: #64748b;
margin: 0;
line-height: 1.5;
}
.auth-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 1rem;
padding: 1.75rem;
box-shadow: 0 4px 24px rgba(15, 23, 42, 0.06);
}
.auth-field {
margin-bottom: 1.25rem;
}
.auth-field:last-of-type {
margin-bottom: 0;
}
.auth-label {
display: block;
font-size: 0.6875rem;
font-weight: 700;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.375rem;
}
.auth-input {
display: block;
width: 100%;
max-width: 100%;
box-sizing: border-box;
border: 1px solid #e2e8f0;
border-radius: 0.75rem;
padding: 0.75rem 1rem;
font-size: 0.875rem;
color: #0f172a;
background: #fff;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.auth-input:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
.auth-input::placeholder {
color: #cbd5e1;
}
.auth-error {
margin-top: 1rem;
font-size: 0.8125rem;
color: #ef4444;
}
.auth-error.hidden {
display: none;
}
.auth-submit {
display: block;
width: 100%;
margin-top: 1.5rem;
padding: 0.875rem 1rem;
border: none;
border-radius: 0.75rem;
background: #2563eb;
color: #fff;
font-size: 0.875rem;
font-weight: 700;
cursor: pointer;
transition: background 0.2s, transform 0.1s;
}
.auth-submit:hover {
background: #1d4ed8;
}
.auth-submit:active {
transform: scale(0.98);
}
.auth-footer {
margin-top: 1.5rem;
text-align: center;
font-size: 0.875rem;
color: #64748b;
}
.auth-footer a {
color: #2563eb;
font-weight: 700;
text-decoration: none;
}
.auth-footer a:hover {
text-decoration: underline;
}
.auth-version-footer {
text-align: center;
padding: 0 1.5rem 1.25rem;
font-size: 0.625rem;
font-weight: 500;
color: #cbd5e1;
letter-spacing: 0.02em;
}

File diff suppressed because one or more lines are too long