관리자 기능과 계정 즐겨찾기 개선
This commit is contained in:
404
admin.html
404
admin.html
@@ -4,121 +4,141 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="stylesheet" href="./style/output.css" />
|
<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/auth-header.css" />
|
||||||
<link rel="stylesheet" href="./style/admin.css" />
|
<link rel="stylesheet" href="./style/deck-history.css" />
|
||||||
<title>관리자 - UNIARE</title>
|
<title>관리자 - UNIARE</title>
|
||||||
</head>
|
</head>
|
||||||
<body class="admin-page">
|
<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="admin-nav">
|
<nav class="sticky top-0 z-50 border-b border-slate-200 bg-white/70 backdrop-blur-xl">
|
||||||
<div class="admin-nav-inner">
|
<div class="mx-auto flex h-16 max-w-7xl items-center justify-between gap-4 px-4 sm:px-6">
|
||||||
<a href="/" class="admin-brand">
|
<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" />
|
<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>
|
</a>
|
||||||
<div id="auth-header-slot"></div>
|
<div id="auth-header-slot"></div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main class="admin-main">
|
<main class="mx-auto max-w-7xl px-4 py-8 pb-20 sm:px-6 sm:pt-16">
|
||||||
<header class="admin-header">
|
<header class="mb-8">
|
||||||
<h1>관리자</h1>
|
<h1 class="mb-2 text-[1.75rem] font-black tracking-tight">관리자</h1>
|
||||||
<p>회원과 저장된 덱을 관리합니다.</p>
|
<p class="m-0 text-sm text-slate-500">회원과 저장된 덱을 관리합니다.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="admin-tabs">
|
<div class="mb-7 flex gap-1 border-b border-slate-200">
|
||||||
<button type="button" data-tab="users" class="admin-tab active">회원 관리</button>
|
<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="admin-tab">덱 관리</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>
|
</div>
|
||||||
|
|
||||||
<section id="tab-users">
|
<section id="tab-users">
|
||||||
<div class="admin-toolbar">
|
<div class="mb-5 flex gap-3">
|
||||||
<input type="search" id="user-search" class="admin-search" placeholder="이메일, 사용자명 검색..." />
|
<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="admin-btn admin-btn-primary">검색</button>
|
<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>
|
||||||
<div id="user-list" class="admin-user-list"></div>
|
<div id="user-list" class="flex flex-col gap-3"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="tab-decks" class="hidden">
|
<section id="tab-decks" class="hidden">
|
||||||
<p id="deck-count" class="admin-count"></p>
|
<div class="deck-history-toolbar mb-6">
|
||||||
<div id="admin-deck-list" class="admin-deck-grid"></div>
|
<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>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- 회원 정보 모달 -->
|
<!-- 회원 정보 모달 -->
|
||||||
<div id="edit-modal" class="admin-modal">
|
<div id="edit-modal" class="fixed inset-0 z-[100] hidden items-center justify-center bg-slate-900/50 p-4">
|
||||||
<div class="admin-modal-box">
|
<div class="w-full max-w-[26rem] overflow-hidden rounded-[1.25rem] bg-white shadow-2xl">
|
||||||
<div class="admin-modal-header">
|
<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="admin-modal-profile">
|
<div class="flex items-center gap-4">
|
||||||
<div class="admin-modal-avatar-wrap">
|
<div class="relative shrink-0">
|
||||||
<img id="edit-avatar-preview" class="admin-modal-avatar" alt="" />
|
<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="admin-modal-avatar-btn" title="사진 변경">📷</button>
|
<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" />
|
<input type="file" id="edit-avatar-file" accept="image/*" class="hidden" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 id="edit-modal-title" class="admin-modal-title">회원 정보</h2>
|
<h2 id="edit-modal-title" class="m-0 text-lg font-extrabold text-slate-900">회원 정보</h2>
|
||||||
<p id="edit-modal-sub" class="admin-modal-sub"></p>
|
<p id="edit-modal-sub" class="mt-1 text-xs text-slate-500"></p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
<div class="admin-modal-content">
|
<div class="px-6 pb-6 pt-5">
|
||||||
<input type="hidden" id="edit-user-id" />
|
<input type="hidden" id="edit-user-id" />
|
||||||
<div class="admin-field">
|
<div class="mb-4">
|
||||||
<label class="admin-label" for="edit-email">이메일</label>
|
<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="admin-input" autocomplete="off" />
|
<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>
|
||||||
<div class="admin-field">
|
<div class="mb-4">
|
||||||
<label class="admin-label" for="edit-username">사용자명</label>
|
<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="admin-input" autocomplete="off" />
|
<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>
|
||||||
<div class="admin-field">
|
<div class="mb-4">
|
||||||
<label class="admin-label" for="edit-role">역할</label>
|
<label class="mb-1.5 block text-[0.6875rem] font-bold uppercase text-slate-400" for="edit-role">역할</label>
|
||||||
<select id="edit-role" class="admin-select">
|
<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="user">일반 회원</option>
|
||||||
<option value="admin">관리자</option>
|
<option value="admin">관리자</option>
|
||||||
</select>
|
</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>
|
||||||
<div class="admin-field">
|
<div class="mb-4">
|
||||||
<label class="admin-label" for="edit-isActive">상태</label>
|
<label class="mb-1.5 block text-[0.6875rem] font-bold uppercase text-slate-400" for="edit-isActive">상태</label>
|
||||||
<select id="edit-isActive" class="admin-select">
|
<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="1">활성</option>
|
||||||
<option value="0">비활성</option>
|
<option value="0">비활성</option>
|
||||||
</select>
|
</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>
|
</div>
|
||||||
<p id="modal-error" class="admin-error hidden"></p>
|
<p id="modal-error" class="mt-2 hidden text-[0.8125rem] text-red-500"></p>
|
||||||
<div class="admin-modal-divider"></div>
|
<div class="my-4 h-px bg-slate-200"></div>
|
||||||
<button type="button" id="open-password-modal" class="admin-btn admin-btn-outline" style="width:100%">비밀번호 변경하기</button>
|
<button type="button" id="open-password-modal" class="w-full cursor-pointer rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-[0.8125rem] font-bold text-slate-600 hover:bg-slate-50">비밀번호 변경하기</button>
|
||||||
<div class="admin-modal-actions-row" style="margin-top:0.75rem">
|
<div class="mt-3 flex gap-2">
|
||||||
<button type="button" id="modal-cancel" class="admin-btn admin-btn-secondary">취소</button>
|
<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="admin-btn admin-btn-primary">저장</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 비밀번호 변경 모달 -->
|
<!-- 비밀번호 변경 모달 -->
|
||||||
<div id="password-modal" class="admin-modal">
|
<div id="password-modal" class="fixed inset-0 z-[100] hidden items-center justify-center bg-slate-900/50 p-4">
|
||||||
<div class="admin-modal-box">
|
<div class="w-full max-w-[26rem] overflow-hidden rounded-[1.25rem] bg-white shadow-2xl">
|
||||||
<div class="admin-modal-header">
|
<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="admin-modal-title">비밀번호 변경</h2>
|
<h2 class="m-0 text-lg font-extrabold text-slate-900">비밀번호 변경</h2>
|
||||||
<p id="password-modal-sub" class="admin-modal-sub"></p>
|
<p id="password-modal-sub" class="mt-1 text-xs text-slate-500"></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-modal-content">
|
<div class="px-6 pb-6 pt-5">
|
||||||
<input type="hidden" id="password-user-id" />
|
<input type="hidden" id="password-user-id" />
|
||||||
<div class="admin-field">
|
<div class="mb-4">
|
||||||
<label class="admin-label" for="password-new">새 비밀번호</label>
|
<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="admin-input" placeholder="8자 이상" autocomplete="new-password" />
|
<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>
|
||||||
<div class="admin-field">
|
<div class="mb-4">
|
||||||
<label class="admin-label" for="password-confirm">비밀번호 확인</label>
|
<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="admin-input" placeholder="다시 입력" autocomplete="new-password" />
|
<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>
|
</div>
|
||||||
<p id="password-error" class="admin-error hidden"></p>
|
<p id="password-error" class="mt-2 hidden text-[0.8125rem] text-red-500"></p>
|
||||||
<div class="admin-modal-actions-row">
|
<div class="flex gap-2">
|
||||||
<button type="button" id="password-cancel" class="admin-btn admin-btn-secondary">취소</button>
|
<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="admin-btn admin-btn-primary">변경</button>
|
<button type="button" id="password-save" class="flex-1 cursor-pointer rounded-xl border-0 bg-slate-800 px-4 py-2.5 text-[0.8125rem] font-bold text-white hover:bg-slate-700">변경</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -136,10 +156,21 @@
|
|||||||
|
|
||||||
const editModal = document.getElementById('edit-modal');
|
const editModal = document.getElementById('edit-modal');
|
||||||
const passwordModal = document.getElementById('password-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 currentAdminId = null;
|
||||||
let editingUser = null;
|
let editingUser = null;
|
||||||
let pendingAvatarImage = null;
|
let pendingAvatarImage = null;
|
||||||
let pendingRemoveAvatar = false;
|
let pendingRemoveAvatar = false;
|
||||||
|
let adminDecks = [];
|
||||||
|
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
return String(str ?? '')
|
return String(str ?? '')
|
||||||
@@ -184,23 +215,23 @@
|
|||||||
const { users, total } = await UaDeckApi.adminGetUsers(q);
|
const { users, total } = await UaDeckApi.adminGetUsers(q);
|
||||||
const list = document.getElementById('user-list');
|
const list = document.getElementById('user-list');
|
||||||
list.innerHTML =
|
list.innerHTML =
|
||||||
`<p class="admin-count">총 ${total}명</p>` +
|
`<p class="mb-4 text-xs text-slate-400">총 ${total}명</p>` +
|
||||||
users
|
users
|
||||||
.map((u) => {
|
.map((u) => {
|
||||||
const isSelf = u.id === currentAdminId;
|
const isSelf = u.id === currentAdminId;
|
||||||
return `
|
return `
|
||||||
<article class="admin-card">
|
<article class="rounded-2xl border border-slate-200 bg-white px-[1.125rem] py-4 shadow-sm">
|
||||||
<div class="admin-user-card">
|
<div class="flex items-center gap-4">
|
||||||
<img src="${escapeHtml(resolveAvatarSrc(u))}" alt="" class="admin-user-avatar" onerror="this.src='${DEFAULT_AVATAR}'" />
|
<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="admin-user-body">
|
<div class="min-w-0 flex-1">
|
||||||
<h3 class="admin-card-title">${escapeHtml(u.username)}${isSelf ? ' <span class="admin-badge admin-badge-type">나</span>' : ''}</h3>
|
<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="admin-card-meta">${escapeHtml(u.email)}</div>
|
<div class="text-xs leading-normal text-slate-400">${escapeHtml(u.email)}</div>
|
||||||
<div class="admin-card-badges">
|
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||||
<span class="admin-badge admin-badge-type">${escapeHtml(u.role)}</span>
|
<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="admin-badge ${u.isActive ? 'admin-badge-public' : 'admin-badge-private'}">${u.isActive ? '활성' : '비활성'}</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>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" data-edit='${JSON.stringify(u).replace(/'/g, ''')}' class="admin-card-btn admin-card-btn-edit">수정</button>
|
<button type="button" data-edit='${JSON.stringify(u).replace(/'/g, ''')}' 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>
|
</div>
|
||||||
</article>`;
|
</article>`;
|
||||||
})
|
})
|
||||||
@@ -233,52 +264,152 @@
|
|||||||
document.getElementById('edit-status-hint').classList.toggle('hidden', !isSelf);
|
document.getElementById('edit-status-hint').classList.toggle('hidden', !isSelf);
|
||||||
document.getElementById('modal-error').classList.add('hidden');
|
document.getElementById('modal-error').classList.add('hidden');
|
||||||
|
|
||||||
editModal.classList.add('open');
|
setModalOpen(editModal, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDeckPreview(d) {
|
function renderDeckPreview(d) {
|
||||||
if (!d.preview_url) {
|
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 src = UaDeckApi.resolveMediaUrl(d.preview_url);
|
||||||
const version = d.updatedAt ? `?v=${encodeURIComponent(d.updatedAt)}` : '';
|
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}개`;
|
* @param {number} bytes
|
||||||
document.getElementById('admin-deck-list').innerHTML = decks
|
* @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) => {
|
.map((d) => {
|
||||||
const publicBadge = d.isPublic
|
const title = escapeHtml(d.name || d.seriesName || d.series || '이름 없음');
|
||||||
? '<span class="admin-badge admin-badge-public">공개</span>'
|
const seriesLabel = escapeHtml(d.seriesName || d.series);
|
||||||
: '<span class="admin-badge admin-badge-private">비공개</span>';
|
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 `
|
return `
|
||||||
<article class="admin-card admin-deck-card">
|
<article class="deck-card" data-code="${escapeHtml(d.shareCode)}">
|
||||||
<div class="admin-deck-preview">${renderDeckPreview(d)}</div>
|
<div class="deck-card-preview">${renderDeckPreview(d)}</div>
|
||||||
<div class="admin-deck-body">
|
<div class="deck-card-body">
|
||||||
<h3 class="admin-card-title">${escapeHtml(d.name || d.seriesName || d.series)}</h3>
|
<div class="deck-card-visibility">
|
||||||
<div class="admin-card-meta">@${escapeHtml(d.username || '비회원')} · 조회 ${d.viewCount}</div>
|
<span class="deck-card-series">${seriesLabel}</span>
|
||||||
<div class="admin-card-meta">/d/${escapeHtml(d.shareCode)}</div>
|
${visibilityBadge}
|
||||||
<div class="admin-card-badges">
|
|
||||||
<span class="admin-badge admin-badge-type">${escapeHtml(d.seriesName || d.series)}</span>
|
|
||||||
${publicBadge}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-card-actions" style="margin-top:0.5rem">
|
<h2 class="deck-card-title">${title}</h2>
|
||||||
<button type="button" data-del="${escapeHtml(d.shareCode)}" class="admin-card-btn admin-card-btn-delete">삭제</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</article>`;
|
</article>`;
|
||||||
})
|
})
|
||||||
.join('');
|
.join('')}</div>`;
|
||||||
|
closeAdminDeckMenus();
|
||||||
|
}
|
||||||
|
|
||||||
document.querySelectorAll('[data-del]').forEach((btn) => {
|
async function loadAdminDecks() {
|
||||||
btn.addEventListener('click', async () => {
|
const { decks } = await UaDeckApi.adminGetDecks();
|
||||||
if (!confirm('삭제하시겠습니까?')) return;
|
adminDecks = decks;
|
||||||
await UaDeckApi.adminDeleteDeck(btn.dataset.del);
|
populateAdminSeriesFilter();
|
||||||
loadAdminDecks();
|
renderAdminDecks();
|
||||||
});
|
}
|
||||||
});
|
|
||||||
|
/**
|
||||||
|
* 미참조 썸네일 현황 조회
|
||||||
|
*/
|
||||||
|
async function loadOrphanPreviews() {
|
||||||
|
const status = document.getElementById('orphan-preview-status');
|
||||||
|
const deleteBtn = document.getElementById('orphan-preview-delete');
|
||||||
|
try {
|
||||||
|
const data = await UaDeckApi.adminGetOrphanPreviews();
|
||||||
|
status.textContent = data.totalCount
|
||||||
|
? `${data.totalCount}개 · ${formatBytes(data.totalBytes)}`
|
||||||
|
: '정리할 파일이 없습니다.';
|
||||||
|
deleteBtn.disabled = data.totalCount === 0;
|
||||||
|
const list = document.getElementById('orphan-preview-list');
|
||||||
|
list.classList.toggle('hidden', data.totalCount === 0);
|
||||||
|
list.innerHTML = data.files
|
||||||
|
.map(
|
||||||
|
(file) =>
|
||||||
|
`<div class="flex flex-col gap-1 border-b border-slate-100 py-2 text-xs last:border-b-0 sm:flex-row sm:justify-between sm:gap-4"><span class="min-w-0 break-all text-slate-700">${escapeHtml(file.fileName)}</span><span class="shrink-0 text-slate-400">${formatBytes(file.size)} · ${new Date(file.modifiedAt).toLocaleDateString('ko-KR')}</span></div>`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
} catch (err) {
|
||||||
|
status.textContent = err.message;
|
||||||
|
deleteBtn.disabled = true;
|
||||||
|
document.getElementById('orphan-preview-list').classList.add('hidden');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('edit-avatar-btn').addEventListener('click', () => {
|
document.getElementById('edit-avatar-btn').addEventListener('click', () => {
|
||||||
@@ -313,11 +444,11 @@
|
|||||||
document.getElementById('password-new').value = '';
|
document.getElementById('password-new').value = '';
|
||||||
document.getElementById('password-confirm').value = '';
|
document.getElementById('password-confirm').value = '';
|
||||||
document.getElementById('password-error').classList.add('hidden');
|
document.getElementById('password-error').classList.add('hidden');
|
||||||
passwordModal.classList.add('open');
|
setModalOpen(passwordModal, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('password-cancel').addEventListener('click', () => {
|
document.getElementById('password-cancel').addEventListener('click', () => {
|
||||||
passwordModal.classList.remove('open');
|
setModalOpen(passwordModal, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('password-save').addEventListener('click', async () => {
|
document.getElementById('password-save').addEventListener('click', async () => {
|
||||||
@@ -337,7 +468,7 @@
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await UaDeckApi.adminResetPassword(document.getElementById('password-user-id').value, pw);
|
await UaDeckApi.adminResetPassword(document.getElementById('password-user-id').value, pw);
|
||||||
passwordModal.classList.remove('open');
|
setModalOpen(passwordModal, false);
|
||||||
alert('비밀번호가 변경되었습니다.');
|
alert('비밀번호가 변경되었습니다.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errEl.textContent = err.message;
|
errEl.textContent = err.message;
|
||||||
@@ -345,21 +476,63 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelectorAll('.admin-tab').forEach((btn) => {
|
document.querySelectorAll('[data-tab]').forEach((btn) => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
document.querySelectorAll('.admin-tab').forEach((b) => b.classList.remove('active'));
|
document.querySelectorAll('[data-tab]').forEach((tabButton) => {
|
||||||
btn.classList.add('active');
|
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-users').classList.toggle('hidden', btn.dataset.tab !== 'users');
|
||||||
document.getElementById('tab-decks').classList.toggle('hidden', btn.dataset.tab !== 'decks');
|
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', () => {
|
document.getElementById('user-search-btn').addEventListener('click', () => {
|
||||||
loadUsers(document.getElementById('user-search').value);
|
loadUsers(document.getElementById('user-search').value);
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('modal-cancel').addEventListener('click', () => {
|
document.getElementById('modal-cancel').addEventListener('click', () => {
|
||||||
editModal.classList.remove('open');
|
setModalOpen(editModal, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('modal-save').addEventListener('click', async () => {
|
document.getElementById('modal-save').addEventListener('click', async () => {
|
||||||
@@ -380,7 +553,7 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await UaDeckApi.adminUpdateUser(id, body);
|
await UaDeckApi.adminUpdateUser(id, body);
|
||||||
editModal.classList.remove('open');
|
setModalOpen(editModal, false);
|
||||||
loadUsers(document.getElementById('user-search').value);
|
loadUsers(document.getElementById('user-search').value);
|
||||||
if (isSelf && window.UaDeckAuth?.refresh) {
|
if (isSelf && window.UaDeckAuth?.refresh) {
|
||||||
await window.UaDeckAuth.refresh();
|
await window.UaDeckAuth.refresh();
|
||||||
@@ -393,7 +566,7 @@
|
|||||||
|
|
||||||
[editModal, passwordModal].forEach((modal) => {
|
[editModal, passwordModal].forEach((modal) => {
|
||||||
modal.addEventListener('click', (e) => {
|
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()) {
|
if (await guardAdmin()) {
|
||||||
loadUsers();
|
loadUsers();
|
||||||
loadAdminDecks();
|
loadAdminDecks();
|
||||||
|
loadOrphanPreviews();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "uadeck-api",
|
"name": "uadeck-api",
|
||||||
"version": "4.0.1",
|
"version": "4.0.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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_user_id ON decks(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_decks_content_hash ON decks(content_hash);
|
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();
|
const deckCols = db.prepare('PRAGMA table_info(decks)').all();
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ app.use(cors({ origin: corsOrigins, credentials: true }));
|
|||||||
app.use(express.json({ limit: '20mb' }));
|
app.use(express.json({ limit: '20mb' }));
|
||||||
|
|
||||||
app.get('/api/v1/health', (_req, res) => {
|
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));
|
app.use('/api/v1/auth', createAuthRouter(db));
|
||||||
@@ -47,7 +47,7 @@ app.use((err, _req, res, _next) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
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(`DB: ${DB_PATH}`);
|
||||||
console.log(`DATAS: ${process.env.DATAS_PATH || '(default ../datas)'}`);
|
console.log(`DATAS: ${process.env.DATAS_PATH || '(default ../datas)'}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import bcrypt from 'bcryptjs';
|
|||||||
import { createRequireAdmin } from '../middleware/auth.js';
|
import { createRequireAdmin } from '../middleware/auth.js';
|
||||||
import { contentHash, validateCards, nowIso } from '../utils.js';
|
import { contentHash, validateCards, nowIso } from '../utils.js';
|
||||||
import { resolveDeckCards } from '../services/cardResolver.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';
|
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) => {
|
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) {
|
if (!row) {
|
||||||
return res.status(404).json({ error: '덱을 찾을 수 없습니다.' });
|
return res.status(404).json({ error: '덱을 찾을 수 없습니다.' });
|
||||||
}
|
}
|
||||||
db.prepare('DELETE FROM decks WHERE id = ?').run(row.id);
|
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;
|
return router;
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import path from 'path';
|
|||||||
import { requireAuth, optionalAuth } from '../middleware/auth.js';
|
import { requireAuth, optionalAuth } from '../middleware/auth.js';
|
||||||
import { resolveDeckCards } from '../services/cardResolver.js';
|
import { resolveDeckCards } from '../services/cardResolver.js';
|
||||||
import { generateShareCode, validateCards, nowIso } from '../utils.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 응답 형식으로 변환
|
* 덱 행을 API 응답 형식으로 변환
|
||||||
@@ -57,6 +62,84 @@ function getAuthor(db, userId) {
|
|||||||
export function createDecksRouter(db) {
|
export function createDecksRouter(db) {
|
||||||
const router = Router();
|
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) => {
|
router.get('/presets', async (_req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
@@ -283,7 +366,8 @@ export function createDecksRouter(db) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
db.prepare('DELETE FROM decks WHERE id = ?').run(row.id);
|
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;
|
return router;
|
||||||
|
|||||||
@@ -53,3 +53,59 @@ export function getPreviewUrl(shareCode, previewImageRecord = null) {
|
|||||||
}
|
}
|
||||||
return 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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -387,6 +387,7 @@
|
|||||||
if (window.UaPageI18n?.applyPageI18n) {
|
if (window.UaPageI18n?.applyPageI18n) {
|
||||||
await window.UaPageI18n.applyPageI18n();
|
await window.UaPageI18n.applyPageI18n();
|
||||||
}
|
}
|
||||||
|
await UaDeckFavorites.init();
|
||||||
loadDecks();
|
loadDecks();
|
||||||
|
|
||||||
document.getElementById('deck-search').addEventListener('input', renderDeckList);
|
document.getElementById('deck-search').addEventListener('input', renderDeckList);
|
||||||
@@ -421,8 +422,12 @@
|
|||||||
|
|
||||||
const favBtn = e.target.closest('[data-favorite]');
|
const favBtn = e.target.closest('[data-favorite]');
|
||||||
if (favBtn) {
|
if (favBtn) {
|
||||||
UaDeckFavorites.toggle(favBtn.dataset.favorite);
|
try {
|
||||||
renderDeckList();
|
await UaDeckFavorites.toggle(favBtn.dataset.favorite);
|
||||||
|
renderDeckList();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message || t('deckFavoriteFailed', '즐겨찾기 변경에 실패했습니다.'));
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,6 +497,7 @@
|
|||||||
closeAllDeckMenus();
|
closeAllDeckMenus();
|
||||||
if (!confirm(t('deckDeleteConfirm', '이 덱을 삭제하시겠습니까?'))) return;
|
if (!confirm(t('deckDeleteConfirm', '이 덱을 삭제하시겠습니까?'))) return;
|
||||||
await UaDeckApi.deleteDeck(deleteBtn.dataset.delete);
|
await UaDeckApi.deleteDeck(deleteBtn.dataset.delete);
|
||||||
|
UaDeckFavorites.forget(deleteBtn.dataset.delete);
|
||||||
allDecks = allDecks.filter((d) => d.share_code !== deleteBtn.dataset.delete);
|
allDecks = allDecks.filter((d) => d.share_code !== deleteBtn.dataset.delete);
|
||||||
if (!allDecks.length) {
|
if (!allDecks.length) {
|
||||||
document.getElementById('deck-toolbar').classList.add('hidden');
|
document.getElementById('deck-toolbar').classList.add('hidden');
|
||||||
|
|||||||
@@ -1,5 +1,23 @@
|
|||||||
# 변경사항
|
# 변경사항
|
||||||
|
|
||||||
|
## v4.0.3
|
||||||
|
|
||||||
|
### 즐겨찾기 계정 동기화
|
||||||
|
|
||||||
|
로그인한 사용자의 즐겨찾기가 계정에 저장됩니다. 다른 브라우저나 기기에서도 같은 계정으로 로그인하면 즐겨찾기가 유지됩니다.
|
||||||
|
|
||||||
|
기존 브라우저에 저장된 즐겨찾기는 로그인 시 계정으로 이전되며, 삭제되거나 더 이상 공개되지 않는 덱은 자동으로 정리됩니다.
|
||||||
|
|
||||||
|
## v4.0.2
|
||||||
|
|
||||||
|
### 관리자 덱 관리 화면 개선
|
||||||
|
|
||||||
|
관리자 화면에서도 내 덱과 동일한 형태로 시리즈, 공개 상태, 수정일을 확인할 수 있습니다. 삭제 기능은 각 카드의 더보기 메뉴로 이동했습니다.
|
||||||
|
|
||||||
|
### 불필요한 덱 썸네일 정리
|
||||||
|
|
||||||
|
덱을 삭제하면 연결된 썸네일도 함께 삭제됩니다. 기존에 남은 미참조 썸네일은 관리자 화면에서 목록과 용량을 확인한 뒤 정리할 수 있습니다.
|
||||||
|
|
||||||
## v4.0.1
|
## v4.0.1
|
||||||
|
|
||||||
### 프로젝트 운영 문서 정비
|
### 프로젝트 운영 문서 정비
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
|
|
||||||
## 스타일
|
## 스타일
|
||||||
|
|
||||||
- 현재 CSS를 Tailwind로 일괄 전환하지 않는다.
|
- 기존 CSS는 `todo.md`의 화면별 순서에 따라 한 화면씩 Tailwind로 전환한다.
|
||||||
- 새 단순 스타일은 Tailwind 유틸리티를 우선 검토하되 기존 페이지 패턴과 빌드 여부를 함께 확인한다.
|
- 새 단순 스타일은 Tailwind 유틸리티를 우선 검토하되 기존 페이지 패턴과 빌드 여부를 함께 확인한다.
|
||||||
- 반복되는 공통 스타일은 공통 CSS 또는 컴포넌트 역할의 클래스로 분리한다.
|
- 반복되는 공통 스타일은 공통 CSS 또는 컴포넌트 역할의 클래스로 분리한다.
|
||||||
- `style/output.css`를 직접 편집하지 않고 `npm run build:css`로 생성한다.
|
- `style/output.css`를 직접 편집하지 않고 `npm run build:css`로 생성한다.
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ Docker 실행: `docker compose -f docker-compose.prod.yml up -d --build`
|
|||||||
|
|
||||||
API 확인: `curl -fsS http://localhost:44082/api/v1/health`
|
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`로 연결한다.
|
NAS 리버스 프록시는 `https://uniare.sori.studio`을 `http://<NAS_IP>:44082`로 연결한다.
|
||||||
|
|
||||||
|
|||||||
@@ -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를 사용
|
## 2026-06-23 v4.0.0 — SQLite 백업은 온라인 백업 API를 사용
|
||||||
|
|
||||||
💡 WAL 모드에서 DB 본파일만 복사하면 아직 병합되지 않은 최신 트랜잭션이 누락될 수 있기 때문.
|
💡 WAL 모드에서 DB 본파일만 복사하면 아직 병합되지 않은 최신 트랜잭션이 누락될 수 있기 때문.
|
||||||
|
|||||||
17
docs/map.md
17
docs/map.md
@@ -66,6 +66,10 @@ script/deck-prefs.js
|
|||||||
덱 즐겨찾기
|
덱 즐겨찾기
|
||||||
script/deck-favorites.js
|
script/deck-favorites.js
|
||||||
|
|
||||||
|
계정 즐겨찾기 DB·API
|
||||||
|
api/src/db.js
|
||||||
|
api/src/routes/decks.js
|
||||||
|
|
||||||
덱 썸네일 확대
|
덱 썸네일 확대
|
||||||
script/deck-preview-hold.js
|
script/deck-preview-hold.js
|
||||||
|
|
||||||
@@ -79,9 +83,6 @@ script/ads.js
|
|||||||
## 페이지 전용 스타일
|
## 페이지 전용 스타일
|
||||||
|
|
||||||
```text
|
```text
|
||||||
로그인·회원가입
|
|
||||||
style/auth-pages.css
|
|
||||||
|
|
||||||
프로필
|
프로필
|
||||||
style/profile.css
|
style/profile.css
|
||||||
|
|
||||||
@@ -89,7 +90,11 @@ style/profile.css
|
|||||||
style/deck-history.css
|
style/deck-history.css
|
||||||
|
|
||||||
관리자
|
관리자
|
||||||
style/admin.css
|
admin.html — 관리자 화면 Tailwind 유틸리티
|
||||||
|
|
||||||
|
로그인·회원가입
|
||||||
|
login.html
|
||||||
|
signup.html
|
||||||
|
|
||||||
Tailwind 입력·산출물
|
Tailwind 입력·산출물
|
||||||
style/input.css
|
style/input.css
|
||||||
@@ -125,6 +130,10 @@ api/src/services/avatarImage.js
|
|||||||
|
|
||||||
덱 미리보기 저장
|
덱 미리보기 저장
|
||||||
api/src/services/previewImage.js
|
api/src/services/previewImage.js
|
||||||
|
|
||||||
|
미참조 덱 미리보기 조회·정리
|
||||||
|
api/src/routes/admin.js
|
||||||
|
api/src/services/previewImage.js
|
||||||
```
|
```
|
||||||
|
|
||||||
## 데이터·자산
|
## 데이터·자산
|
||||||
|
|||||||
36
docs/spec.md
36
docs/spec.md
@@ -3,7 +3,7 @@
|
|||||||
## 프로젝트 개요
|
## 프로젝트 개요
|
||||||
|
|
||||||
- 서비스명: `UNIARE - Union Arena Deck Builder`
|
- 서비스명: `UNIARE - Union Arena Deck Builder`
|
||||||
- 현재 버전: `v4.0.1`
|
- 현재 버전: `v4.0.3`
|
||||||
- 프론트: 정적 HTML, CSS, JavaScript
|
- 프론트: 정적 HTML, CSS, JavaScript
|
||||||
- 백엔드: Express, SQLite(`better-sqlite3`)
|
- 백엔드: Express, SQLite(`better-sqlite3`)
|
||||||
- 운영: Docker Compose 기반 Nginx + API
|
- 운영: Docker Compose 기반 Nginx + API
|
||||||
@@ -26,6 +26,14 @@
|
|||||||
| `/admin.html` | 회원과 덱 관리 |
|
| `/admin.html` | 회원과 덱 관리 |
|
||||||
| `/d/<shareCode>` | Nginx에서 `cardList.html?d=<shareCode>`로 연결 |
|
| `/d/<shareCode>` | Nginx에서 `cardList.html?d=<shareCode>`로 연결 |
|
||||||
|
|
||||||
|
## 검색 노출
|
||||||
|
|
||||||
|
- 메인 검색 제목은 `UNIARE | 유니온 아레나 덱 빌더`를 사용한다.
|
||||||
|
- 메인 설명은 카드 검색, 덱 제작·저장·공유, 공개 덱과 즐겨찾기 기능을 안내한다.
|
||||||
|
- JavaScript 실행 전에도 메인 제목과 설명이 HTML에 표시된다.
|
||||||
|
- canonical, Open Graph, Twitter Card, WebSite·WebApplication 구조화 데이터를 제공한다.
|
||||||
|
- 번역 파일 로딩 실패 시 내부 오류 대신 새로고침 안내를 표시한다.
|
||||||
|
|
||||||
## 카드 데이터
|
## 카드 데이터
|
||||||
|
|
||||||
- 데이터 위치: `datas/*.js`
|
- 데이터 위치: `datas/*.js`
|
||||||
@@ -60,6 +68,8 @@
|
|||||||
- `images/**`는 Git LFS로 관리한다.
|
- `images/**`는 Git LFS로 관리한다.
|
||||||
- 아바타와 덱 미리보기는 API 데이터 디렉터리에 저장하며 Git에 포함하지 않는다.
|
- 아바타와 덱 미리보기는 API 데이터 디렉터리에 저장하며 Git에 포함하지 않는다.
|
||||||
- 덱 저장 미리보기는 1200px 기준 PNG로 정규화한다.
|
- 덱 저장 미리보기는 1200px 기준 PNG로 정규화한다.
|
||||||
|
- 덱 삭제 시 해당 공유 코드의 미리보기 PNG도 함께 삭제한다.
|
||||||
|
- 관리자는 DB에서 참조하지 않는 미리보기 파일을 조회하고 일괄 삭제할 수 있다.
|
||||||
|
|
||||||
## 브라우저 상태
|
## 브라우저 상태
|
||||||
|
|
||||||
@@ -70,7 +80,7 @@
|
|||||||
| `selectedSeries` | 현재 시리즈 |
|
| `selectedSeries` | 현재 시리즈 |
|
||||||
| `uadeck_token` | JWT |
|
| `uadeck_token` | JWT |
|
||||||
| `uadeck_user` | 로그인 사용자 캐시 |
|
| `uadeck_user` | 로그인 사용자 캐시 |
|
||||||
| 덱 즐겨찾기 키 | 공유 코드 기반 로컬 즐겨찾기 |
|
| `uadeck.deckFavorites` | 비로그인 즐겨찾기 및 로그인 시 최초 이전 대상 |
|
||||||
|
|
||||||
## 인증 및 권한
|
## 인증 및 권한
|
||||||
|
|
||||||
@@ -80,6 +90,8 @@
|
|||||||
- `admin`은 회원 상태·역할·비밀번호와 전체 덱을 관리할 수 있다.
|
- `admin`은 회원 상태·역할·비밀번호와 전체 덱을 관리할 수 있다.
|
||||||
- 관리자는 자신의 관리자 역할을 해제하거나 자신의 계정을 비활성화할 수 없다.
|
- 관리자는 자신의 관리자 역할을 해제하거나 자신의 계정을 비활성화할 수 없다.
|
||||||
- 비공개 덱은 소유자와 관리자만 조회할 수 있다.
|
- 비공개 덱은 소유자와 관리자만 조회할 수 있다.
|
||||||
|
- 로그인 사용자의 즐겨찾기는 계정 DB에 저장되어 브라우저와 기기가 달라도 유지된다.
|
||||||
|
- 비로그인 즐겨찾기는 브라우저에 저장하고 로그인 시 유효한 덱만 계정으로 이전한다.
|
||||||
|
|
||||||
## 덱 저장 정책
|
## 덱 저장 정책
|
||||||
|
|
||||||
@@ -125,6 +137,14 @@
|
|||||||
| `preview_image` | 저장된 미리보기 파일 정보 |
|
| `preview_image` | 저장된 미리보기 파일 정보 |
|
||||||
| `created_at`, `updated_at` | 생성·수정 시각 |
|
| `created_at`, `updated_at` | 생성·수정 시각 |
|
||||||
|
|
||||||
|
### `deck_favorites`
|
||||||
|
|
||||||
|
| 컬럼 | 설명 |
|
||||||
|
|---|---|
|
||||||
|
| `user_id` | 즐겨찾기 사용자, 사용자 삭제 시 함께 삭제 |
|
||||||
|
| `share_code` | 즐겨찾기 덱, 덱 삭제 시 함께 삭제 |
|
||||||
|
| `created_at` | 즐겨찾기 등록 시각 |
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
### 공통
|
### 공통
|
||||||
@@ -155,6 +175,10 @@
|
|||||||
| POST | `/api/v1/decks` | 공개 | 회원 또는 비회원 덱 저장 |
|
| POST | `/api/v1/decks` | 공개 | 회원 또는 비회원 덱 저장 |
|
||||||
| PUT | `/api/v1/decks/:shareCode` | 소유자·관리자 | 덱 수정 |
|
| PUT | `/api/v1/decks/:shareCode` | 소유자·관리자 | 덱 수정 |
|
||||||
| DELETE | `/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` | 비밀번호 초기화 |
|
| POST | `/api/v1/admin/users/:id/reset-password` | 비밀번호 초기화 |
|
||||||
| GET | `/api/v1/admin/decks` | 전체 덱 목록 |
|
| GET | `/api/v1/admin/decks` | 전체 덱 목록 |
|
||||||
| DELETE | `/api/v1/admin/decks/:shareCode` | 덱 삭제 |
|
| DELETE | `/api/v1/admin/decks/:shareCode` | 덱 삭제 |
|
||||||
|
| GET | `/api/v1/admin/previews/orphans` | 미참조 덱 미리보기 목록 |
|
||||||
|
| DELETE | `/api/v1/admin/previews/orphans` | 미참조 덱 미리보기 일괄 삭제 |
|
||||||
| POST | `/api/v1/admin/decks/preset` | 레거시 프리셋 생성 |
|
| POST | `/api/v1/admin/decks/preset` | 레거시 프리셋 생성 |
|
||||||
|
|
||||||
## 스타일
|
## 스타일
|
||||||
|
|
||||||
- 현재는 Tailwind 유틸리티, 공통 CSS, 페이지 전용 CSS, 일부 인라인 스타일을 혼용한다.
|
- 현재는 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/output.css`는 `style/input.css`에서 생성하는 빌드 산출물이다.
|
||||||
- 헤더 로고 크기는 `style/brand.css`만 기준으로 사용한다.
|
- 헤더 로고 크기는 `style/brand.css`만 기준으로 사용한다.
|
||||||
- 기본 글꼴은 Pretendard 계열을 우선 사용한다.
|
- 기본 글꼴은 Pretendard 계열을 우선 사용한다.
|
||||||
@@ -180,6 +209,5 @@
|
|||||||
|
|
||||||
- `cardList.html`, `result.html`의 인라인 스크립트가 커서 변경 영향 범위가 넓다.
|
- `cardList.html`, `result.html`의 인라인 스크립트가 커서 변경 영향 범위가 넓다.
|
||||||
- `admin.html`은 3개 언어 번역 적용이 완료되지 않았다.
|
- `admin.html`은 3개 언어 번역 적용이 완료되지 않았다.
|
||||||
- 즐겨찾기는 브라우저별 로컬 상태라 계정 간 동기화되지 않는다.
|
|
||||||
- Homepage 전용 `GET /api/homepage` 엔드포인트는 아직 구현되지 않았다.
|
- Homepage 전용 `GET /api/homepage` 엔드포인트는 아직 구현되지 않았다.
|
||||||
- 현재 Compose의 컨테이너명(`uniare-api`, `uniare-web`)과 데이터 볼륨명(`uniare-data`)은 고정값이며 환경 변수화되지 않았다.
|
- 현재 Compose의 컨테이너명(`uniare-api`, `uniare-web`)과 데이터 볼륨명(`uniare-data`)은 고정값이며 환경 변수화되지 않았다.
|
||||||
|
|||||||
16
docs/todo.md
16
docs/todo.md
@@ -3,6 +3,7 @@
|
|||||||
## 운영
|
## 운영
|
||||||
|
|
||||||
- NAS에서 `uniare-web`, `uniare-api` 스택의 재기동과 헬스 체크를 최종 확인한다.
|
- NAS에서 `uniare-web`, `uniare-api` 스택의 재기동과 헬스 체크를 최종 확인한다.
|
||||||
|
- SEO 메타데이터 배포 후 Google Search Console에서 메인 URL 재색인을 요청한다.
|
||||||
- 운영 `.env`의 `JWT_SECRET`, 관리자 계정, CORS 허용 도메인을 실제 환경 기준으로 점검한다.
|
- 운영 `.env`의 `JWT_SECRET`, 관리자 계정, CORS 허용 도메인을 실제 환경 기준으로 점검한다.
|
||||||
- DB와 업로드 이미지(`api/data/previews`, `api/data/avatars`)의 정기 백업·복구 절차를 검증한다.
|
- DB와 업로드 이미지(`api/data/previews`, `api/data/avatars`)의 정기 백업·복구 절차를 검증한다.
|
||||||
- 원격 저장소의 Git LFS 업로드 상태와 용량 정책을 확인한다.
|
- 원격 저장소의 Git LFS 업로드 상태와 용량 정책을 확인한다.
|
||||||
@@ -18,5 +19,16 @@
|
|||||||
|
|
||||||
- `cardList.html`, `result.html`의 대형 인라인 스크립트를 기능 단위 모듈로 분리한다.
|
- `cardList.html`, `result.html`의 대형 인라인 스크립트를 기능 단위 모듈로 분리한다.
|
||||||
- `datas/ua99 sample.js`, `datas/ua99 sample mini.js`의 보존 목적과 배포 제외 여부를 확정한다.
|
- `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`)
|
||||||
|
|||||||
@@ -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 — 프로젝트 문서 체계 정비
|
## 2026-06-23 / v4.0.1 — 프로젝트 문서 체계 정비
|
||||||
|
|
||||||
### 수행 작업
|
### 수행 작업
|
||||||
|
|||||||
54
index.html
54
index.html
@@ -7,11 +7,18 @@
|
|||||||
<link rel="stylesheet" href="./style/output.css" />
|
<link rel="stylesheet" href="./style/output.css" />
|
||||||
<link rel="stylesheet" href="./style/brand.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 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/navigation.js"></script>
|
||||||
<script src="/script/api.js"></script>
|
<script src="/script/api.js"></script>
|
||||||
<link rel="stylesheet" href="./style/auth-header.css" />
|
<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>
|
<style>
|
||||||
body {
|
body {
|
||||||
@@ -49,11 +56,39 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<meta property="og:type" content="article" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:title" content="유니온 아레나 덱빌더" />
|
<meta property="og:site_name" content="UNIARE" />
|
||||||
<meta property="og:description" content="자신만의 덱을 만들고, 다른 플레이어들과 공유해보세요." />
|
<meta property="og:locale" content="ko_KR" />
|
||||||
<meta property="og:image" content="https://uniare.zenncloud.com/images/logo_unionarena.png" />
|
<meta property="og:title" content="UNIARE | 유니온 아레나 덱 빌더" />
|
||||||
<meta property="og:url" content="https://uniare.zenncloud.com/" />
|
<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>
|
</head>
|
||||||
|
|
||||||
<body class="text-slate-900">
|
<body class="text-slate-900">
|
||||||
@@ -76,8 +111,8 @@
|
|||||||
|
|
||||||
<main class="max-w-7xl mx-auto px-6 pt-12 pb-24">
|
<main class="max-w-7xl mx-auto px-6 pt-12 pb-24">
|
||||||
<header class="text-center mb-16">
|
<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>
|
<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>
|
<p id="display-main-desc" class="text-slate-500 text-lg max-w-2xl mx-auto leading-relaxed">유니온 아레나 카드를 검색하고 나만의 덱을 만들어 저장·공유해 보세요.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div id="app">
|
<div id="app">
|
||||||
@@ -153,7 +188,8 @@
|
|||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
document.getElementById('series-container').innerHTML = `
|
document.getElementById('series-container').innerHTML = `
|
||||||
<div class="col-span-full py-20 text-center">
|
<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>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
43
login.html
43
login.html
@@ -4,45 +4,44 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="stylesheet" href="./style/output.css" />
|
<link rel="stylesheet" href="./style/output.css" />
|
||||||
<link rel="stylesheet" href="./style/auth-pages.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" />
|
|
||||||
<title>로그인 - UNIARE</title>
|
<title>로그인 - UNIARE</title>
|
||||||
</head>
|
</head>
|
||||||
<body class="auth-page">
|
<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="auth-nav">
|
<nav class="sticky top-0 z-50 border-b border-slate-200 bg-white/70 backdrop-blur-xl">
|
||||||
<div class="auth-nav-inner">
|
<div class="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6">
|
||||||
<a href="/" class="auth-brand">
|
<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" />
|
<img src="/images/assets/uniare-h48.png" alt="UNIARE" class="app-brand-logo" height="32" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main class="auth-main">
|
<main class="mx-auto max-w-[28rem] px-6 py-12 sm:py-16">
|
||||||
<header class="auth-header">
|
<header class="mb-8 text-center">
|
||||||
<h1 data-i18n="loginPageTitle">로그인</h1>
|
<h1 class="mb-2 text-[1.75rem] font-black tracking-[-0.02em] text-slate-900" data-i18n="loginPageTitle">로그인</h1>
|
||||||
<p data-i18n-html="loginPageDesc">덱을 저장하고 관리하려면<br />계정으로 로그인하세요.</p>
|
<p class="m-0 text-sm leading-normal text-slate-500" data-i18n-html="loginPageDesc">덱을 저장하고 관리하려면<br />계정으로 로그인하세요.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<form id="login-form" class="auth-card">
|
<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="auth-field">
|
<div class="mb-5">
|
||||||
<label class="auth-label" for="email" data-i18n="emailLabel">이메일</label>
|
<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="auth-input" placeholder="you@example.com" autocomplete="email" />
|
<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>
|
||||||
<div class="auth-field">
|
<div>
|
||||||
<label class="auth-label" for="password" data-i18n="passwordLabel">비밀번호</label>
|
<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="auth-input" placeholder="••••••••" autocomplete="current-password" />
|
<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>
|
</div>
|
||||||
<p id="error-msg" class="auth-error hidden"></p>
|
<p id="error-msg" class="mt-4 hidden text-[0.8125rem] text-red-500"></p>
|
||||||
<button type="submit" class="auth-submit" data-i18n="loginButton">로그인</button>
|
<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>
|
</form>
|
||||||
|
|
||||||
<p class="auth-footer">
|
<p class="mt-6 text-center text-sm text-slate-500">
|
||||||
<span data-i18n="noAccount">계정이 없으신가요?</span>
|
<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>
|
</p>
|
||||||
</main>
|
</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/page-i18n.js"></script>
|
||||||
<script src="/script/api.js"></script>
|
<script src="/script/api.js"></script>
|
||||||
|
|||||||
11
preset.html
11
preset.html
@@ -247,6 +247,7 @@
|
|||||||
if (window.UaPageI18n?.applyPageI18n) {
|
if (window.UaPageI18n?.applyPageI18n) {
|
||||||
await window.UaPageI18n.applyPageI18n();
|
await window.UaPageI18n.applyPageI18n();
|
||||||
}
|
}
|
||||||
|
await UaDeckFavorites.init();
|
||||||
loadPublicDecks();
|
loadPublicDecks();
|
||||||
document.getElementById('deck-search').addEventListener('input', renderList);
|
document.getElementById('deck-search').addEventListener('input', renderList);
|
||||||
document.getElementById('deck-series-filter').addEventListener('change', renderList);
|
document.getElementById('deck-series-filter').addEventListener('change', renderList);
|
||||||
@@ -260,11 +261,15 @@
|
|||||||
renderList();
|
renderList();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('preset-list').addEventListener('click', (e) => {
|
document.getElementById('preset-list').addEventListener('click', async (e) => {
|
||||||
const favBtn = e.target.closest('[data-favorite]');
|
const favBtn = e.target.closest('[data-favorite]');
|
||||||
if (favBtn) {
|
if (favBtn) {
|
||||||
UaDeckFavorites.toggle(favBtn.dataset.favorite);
|
try {
|
||||||
renderList();
|
await UaDeckFavorites.toggle(favBtn.dataset.favorite);
|
||||||
|
renderList();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message || t('deckFavoriteFailed', '즐겨찾기 변경에 실패했습니다.'));
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -165,6 +165,23 @@ const UaDeckApi = (() => {
|
|||||||
|
|
||||||
getPresets: () => request('/decks/presets'),
|
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) =>
|
adminGetUsers: (q = '', page = 1) =>
|
||||||
request(`/admin/users?q=${encodeURIComponent(q)}&page=${page}`),
|
request(`/admin/users?q=${encodeURIComponent(q)}&page=${page}`),
|
||||||
@@ -185,6 +202,11 @@ const UaDeckApi = (() => {
|
|||||||
|
|
||||||
adminGetDecks: () => request('/admin/decks'),
|
adminGetDecks: () => request('/admin/decks'),
|
||||||
|
|
||||||
|
adminGetOrphanPreviews: () => request('/admin/previews/orphans'),
|
||||||
|
|
||||||
|
adminDeleteOrphanPreviews: () =>
|
||||||
|
request('/admin/previews/orphans', { method: 'DELETE' }),
|
||||||
|
|
||||||
/** @param {object} body */
|
/** @param {object} body */
|
||||||
adminCreatePreset: (body) =>
|
adminCreatePreset: (body) =>
|
||||||
request('/admin/decks/preset', { method: 'POST', body: JSON.stringify(body) }),
|
request('/admin/decks/preset', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* 덱 즐겨찾기 (localStorage, share_code 기준)
|
* 덱 즐겨찾기
|
||||||
|
* 로그인 사용자는 계정 DB, 비로그인 사용자는 localStorage를 사용한다.
|
||||||
*/
|
*/
|
||||||
(function initDeckFavorites() {
|
(function initDeckFavorites() {
|
||||||
const STORAGE_KEY = 'uadeck.deckFavorites';
|
const STORAGE_KEY = 'uadeck.deckFavorites';
|
||||||
|
/** @type {Set<string>} */
|
||||||
|
let favorites = new Set();
|
||||||
|
/** @type {Promise<void>|null} */
|
||||||
|
let initPromise = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* 브라우저 즐겨찾기 읽기
|
||||||
* @returns {Set<string>}
|
* @returns {Set<string>}
|
||||||
*/
|
*/
|
||||||
function readSet() {
|
function readLocalSet() {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
const list = raw ? JSON.parse(raw) : [];
|
const list = raw ? JSON.parse(raw) : [];
|
||||||
@@ -18,43 +24,87 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* 브라우저 즐겨찾기 저장
|
||||||
* @param {Set<string>} set
|
* @param {Set<string>} set
|
||||||
*/
|
*/
|
||||||
function writeSet(set) {
|
function writeLocalSet(set) {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify([...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
|
* @param {string} shareCode
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
function isFavorite(shareCode) {
|
function isFavorite(shareCode) {
|
||||||
if (!shareCode) return false;
|
return Boolean(shareCode) && favorites.has(shareCode);
|
||||||
return readSet().has(shareCode);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* 즐겨찾기 상태 변경
|
||||||
* @param {string} shareCode
|
* @param {string} shareCode
|
||||||
* @returns {boolean} 토글 후 즐겨찾기 여부
|
* @returns {Promise<boolean>} 변경 후 즐겨찾기 여부
|
||||||
*/
|
*/
|
||||||
function toggle(shareCode) {
|
async function toggle(shareCode) {
|
||||||
if (!shareCode) return false;
|
if (!shareCode) return false;
|
||||||
const set = readSet();
|
await init();
|
||||||
if (set.has(shareCode)) {
|
const next = !favorites.has(shareCode);
|
||||||
set.delete(shareCode);
|
|
||||||
} else {
|
if (UaDeckApi.getToken()) {
|
||||||
set.add(shareCode);
|
if (next) {
|
||||||
|
await UaDeckApi.addFavorite(shareCode);
|
||||||
|
} else {
|
||||||
|
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[]}
|
* @returns {string[]}
|
||||||
*/
|
*/
|
||||||
function getAll() {
|
function getAll() {
|
||||||
return [...readSet()];
|
return [...favorites];
|
||||||
}
|
}
|
||||||
|
|
||||||
window.UaDeckFavorites = { isFavorite, toggle, getAll };
|
window.UaDeckFavorites = { init, isFavorite, toggle, forget, getAll };
|
||||||
})();
|
})();
|
||||||
|
|||||||
53
signup.html
53
signup.html
@@ -4,49 +4,48 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="stylesheet" href="./style/output.css" />
|
<link rel="stylesheet" href="./style/output.css" />
|
||||||
<link rel="stylesheet" href="./style/auth-pages.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" />
|
|
||||||
<title>회원가입 - UNIARE</title>
|
<title>회원가입 - UNIARE</title>
|
||||||
</head>
|
</head>
|
||||||
<body class="auth-page">
|
<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="auth-nav">
|
<nav class="sticky top-0 z-50 border-b border-slate-200 bg-white/70 backdrop-blur-xl">
|
||||||
<div class="auth-nav-inner">
|
<div class="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6">
|
||||||
<a href="/" class="auth-brand">
|
<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" />
|
<img src="/images/assets/uniare-h48.png" alt="UNIARE" class="app-brand-logo" height="32" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main class="auth-main">
|
<main class="mx-auto max-w-[28rem] px-6 py-12 sm:py-16">
|
||||||
<header class="auth-header">
|
<header class="mb-8 text-center">
|
||||||
<h1 data-i18n="signupPageTitle">회원가입</h1>
|
<h1 class="mb-2 text-[1.75rem] font-black tracking-[-0.02em] text-slate-900" data-i18n="signupPageTitle">회원가입</h1>
|
||||||
<p data-i18n-html="signupPageDesc">UNIARE 계정을 만들고<br />덱을 저장·공유해 보세요.</p>
|
<p class="m-0 text-sm leading-normal text-slate-500" data-i18n-html="signupPageDesc">UNIARE 계정을 만들고<br />덱을 저장·공유해 보세요.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<form id="signup-form" class="auth-card">
|
<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="auth-field">
|
<div class="mb-5">
|
||||||
<label class="auth-label" for="email" data-i18n="emailLabel">이메일</label>
|
<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="auth-input" placeholder="you@example.com" autocomplete="email" />
|
<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>
|
||||||
<div class="auth-field">
|
<div class="mb-5">
|
||||||
<label class="auth-label" for="username" data-i18n="usernameLabel">사용자명</label>
|
<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="auth-input" placeholder="deckmaster" autocomplete="username" />
|
<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>
|
||||||
<div class="auth-field">
|
<div class="mb-5">
|
||||||
<label class="auth-label" for="password" data-i18n="passwordLabel">비밀번호</label>
|
<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="auth-input" data-i18n-placeholder="signupPasswordPlaceholder" placeholder="8자 이상" autocomplete="new-password" />
|
<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>
|
||||||
<div class="auth-field">
|
<div>
|
||||||
<label class="auth-label" for="password-confirm" data-i18n="passwordConfirmLabel">비밀번호 확인</label>
|
<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="auth-input" data-i18n-placeholder="passwordConfirmPlaceholder" placeholder="비밀번호 재입력" autocomplete="new-password" />
|
<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>
|
</div>
|
||||||
<p id="error-msg" class="auth-error hidden"></p>
|
<p id="error-msg" class="mt-4 hidden text-[0.8125rem] text-red-500"></p>
|
||||||
<button type="submit" class="auth-submit" data-i18n="signupButton">가입하기</button>
|
<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>
|
</form>
|
||||||
|
|
||||||
<p class="auth-footer">
|
<p class="mt-6 text-center text-sm text-slate-500">
|
||||||
<span data-i18n="hasAccount">이미 계정이 있으신가요?</span>
|
<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>
|
</p>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
520
style/admin.css
520
style/admin.css
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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
Reference in New Issue
Block a user