v4.0.0: 회원·덱 API, 인증 UI, 프로필 및 레이아웃 통일
Express/SQLite API와 Docker 구성을 추가하고 로그인·덱 저장·공개 덱·관리자·프로필 화면을 구현했다. 결과 화면 저장 UX, 헤더 프로필, 애드센스용 app-shell 레이아웃(1280px) 및 문서를 반영했다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
# Docker 빌드 시 API 이미지에서 제외
|
||||
api/node_modules
|
||||
api/data
|
||||
.git
|
||||
.env
|
||||
*.md
|
||||
docs/
|
||||
.cursor/
|
||||
.cursorrules
|
||||
22
.env.example
Normal file
22
.env.example
Normal file
@@ -0,0 +1,22 @@
|
||||
# UADECK API 환경변수 — 이 파일을 .env로 복사한 뒤 값을 채우세요
|
||||
|
||||
# --- API ---
|
||||
API_PORT=3000
|
||||
JWT_SECRET=여기에-32자-이상-랜덤-시크릿-입력
|
||||
JWT_EXPIRES=7d
|
||||
|
||||
# --- 웹 (docker-compose.prod.yml) ---
|
||||
WEB_PORT=8080
|
||||
|
||||
# --- 관리자 (최초 기동 시 자동 생성) ---
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=change-me-8chars
|
||||
ADMIN_USERNAME=admin
|
||||
|
||||
# --- CORS (옆 프로젝트·로컬 개발 포트 포함, 쉼표 구분) ---
|
||||
CORS_ORIGINS=https://uniare.sori.studio,http://localhost:8080,http://127.0.0.1:8080,http://localhost:5500,http://127.0.0.1:5500,http://localhost:5173
|
||||
|
||||
# --- 로컬 api:dev 전용 (npm run api:dev) ---
|
||||
DATAS_PATH=./datas
|
||||
DB_PATH=./api/data/uadeck.db
|
||||
PORT=3000
|
||||
405
admin.html
Normal file
405
admin.html
Normal file
@@ -0,0 +1,405 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="stylesheet" href="./style/output.css" />
|
||||
<link rel="stylesheet" href="./style/auth-header.css" />
|
||||
<link rel="stylesheet" href="./style/admin.css" />
|
||||
<title>관리자 - UADECK</title>
|
||||
</head>
|
||||
<body class="admin-page">
|
||||
<nav class="admin-nav">
|
||||
<div class="admin-nav-inner">
|
||||
<a href="/" class="admin-brand">UADECK <span>v4.0.0</span> 관리자</a>
|
||||
<div id="auth-header-slot"></div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="admin-main">
|
||||
<header class="admin-header">
|
||||
<h1>관리자</h1>
|
||||
<p>회원과 저장된 덱을 관리합니다.</p>
|
||||
</header>
|
||||
|
||||
<div class="admin-tabs">
|
||||
<button type="button" data-tab="users" class="admin-tab active">회원 관리</button>
|
||||
<button type="button" data-tab="decks" class="admin-tab">덱 관리</button>
|
||||
</div>
|
||||
|
||||
<section id="tab-users">
|
||||
<div class="admin-toolbar">
|
||||
<input type="search" id="user-search" class="admin-search" placeholder="이메일, 사용자명 검색..." />
|
||||
<button type="button" id="user-search-btn" class="admin-btn admin-btn-primary">검색</button>
|
||||
</div>
|
||||
<div id="user-list" class="admin-user-list"></div>
|
||||
</section>
|
||||
|
||||
<section id="tab-decks" class="hidden">
|
||||
<p id="deck-count" class="admin-count"></p>
|
||||
<div id="admin-deck-list" class="admin-deck-grid"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- 회원 정보 모달 -->
|
||||
<div id="edit-modal" class="admin-modal">
|
||||
<div class="admin-modal-box">
|
||||
<div class="admin-modal-header">
|
||||
<div class="admin-modal-profile">
|
||||
<div class="admin-modal-avatar-wrap">
|
||||
<img id="edit-avatar-preview" class="admin-modal-avatar" alt="" />
|
||||
<button type="button" id="edit-avatar-btn" class="admin-modal-avatar-btn" title="사진 변경">📷</button>
|
||||
<input type="file" id="edit-avatar-file" accept="image/*" class="hidden" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 id="edit-modal-title" class="admin-modal-title">회원 정보</h2>
|
||||
<p id="edit-modal-sub" class="admin-modal-sub"></p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" id="edit-avatar-remove" class="admin-modal-avatar-remove hidden">프로필 사진 제거</button>
|
||||
</div>
|
||||
<div class="admin-modal-content">
|
||||
<input type="hidden" id="edit-user-id" />
|
||||
<div class="admin-field">
|
||||
<label class="admin-label" for="edit-email">이메일</label>
|
||||
<input type="email" id="edit-email" class="admin-input" autocomplete="off" />
|
||||
</div>
|
||||
<div class="admin-field">
|
||||
<label class="admin-label" for="edit-username">사용자명</label>
|
||||
<input type="text" id="edit-username" class="admin-input" autocomplete="off" />
|
||||
</div>
|
||||
<div class="admin-field">
|
||||
<label class="admin-label" for="edit-role">역할</label>
|
||||
<select id="edit-role" class="admin-select">
|
||||
<option value="user">일반 회원</option>
|
||||
<option value="admin">관리자</option>
|
||||
</select>
|
||||
<p id="edit-role-hint" class="admin-field-hint warn hidden">본인 계정의 관리자 권한은 변경할 수 없습니다.</p>
|
||||
</div>
|
||||
<div class="admin-field">
|
||||
<label class="admin-label" for="edit-isActive">상태</label>
|
||||
<select id="edit-isActive" class="admin-select">
|
||||
<option value="1">활성</option>
|
||||
<option value="0">비활성</option>
|
||||
</select>
|
||||
<p id="edit-status-hint" class="admin-field-hint warn hidden">본인 계정은 비활성화할 수 없습니다.</p>
|
||||
</div>
|
||||
<p id="modal-error" class="admin-error hidden"></p>
|
||||
<div class="admin-modal-divider"></div>
|
||||
<button type="button" id="open-password-modal" class="admin-btn admin-btn-outline" style="width:100%">비밀번호 변경하기</button>
|
||||
<div class="admin-modal-actions-row" style="margin-top:0.75rem">
|
||||
<button type="button" id="modal-cancel" class="admin-btn admin-btn-secondary">취소</button>
|
||||
<button type="button" id="modal-save" class="admin-btn admin-btn-primary">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 비밀번호 변경 모달 -->
|
||||
<div id="password-modal" class="admin-modal">
|
||||
<div class="admin-modal-box">
|
||||
<div class="admin-modal-header">
|
||||
<h2 class="admin-modal-title">비밀번호 변경</h2>
|
||||
<p id="password-modal-sub" class="admin-modal-sub"></p>
|
||||
</div>
|
||||
<div class="admin-modal-content">
|
||||
<input type="hidden" id="password-user-id" />
|
||||
<div class="admin-field">
|
||||
<label class="admin-label" for="password-new">새 비밀번호</label>
|
||||
<input type="password" id="password-new" class="admin-input" placeholder="8자 이상" autocomplete="new-password" />
|
||||
</div>
|
||||
<div class="admin-field">
|
||||
<label class="admin-label" for="password-confirm">비밀번호 확인</label>
|
||||
<input type="password" id="password-confirm" class="admin-input" placeholder="다시 입력" autocomplete="new-password" />
|
||||
</div>
|
||||
<p id="password-error" class="admin-error hidden"></p>
|
||||
<div class="admin-modal-actions-row">
|
||||
<button type="button" id="password-cancel" class="admin-btn admin-btn-secondary">취소</button>
|
||||
<button type="button" id="password-save" class="admin-btn admin-btn-primary">변경</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/script/api.js"></script>
|
||||
<script src="/script/image-utils.js"></script>
|
||||
<script src="/script/auth-header.js"></script>
|
||||
<script>
|
||||
const DEFAULT_AVATAR =
|
||||
'data:image/svg+xml,' +
|
||||
encodeURIComponent(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#94a3b8"><path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"/></svg>',
|
||||
);
|
||||
|
||||
const editModal = document.getElementById('edit-modal');
|
||||
const passwordModal = document.getElementById('password-modal');
|
||||
let currentAdminId = null;
|
||||
let editingUser = null;
|
||||
let pendingAvatarImage = null;
|
||||
let pendingRemoveAvatar = false;
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function resolveAvatarSrc(userOrUrl) {
|
||||
const avatarUrl = typeof userOrUrl === 'object' ? userOrUrl?.avatarUrl : userOrUrl;
|
||||
const updatedAt = typeof userOrUrl === 'object' ? userOrUrl?.updatedAt : null;
|
||||
if (!avatarUrl) return DEFAULT_AVATAR;
|
||||
let url = UaDeckApi.resolveMediaUrl(avatarUrl) || DEFAULT_AVATAR;
|
||||
if (updatedAt && url && !url.startsWith('data:')) {
|
||||
url += (url.includes('?') ? '&' : '?') + 't=' + encodeURIComponent(updatedAt);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
async function guardAdmin() {
|
||||
if (!UaDeckApi.getToken()) {
|
||||
location.href = '/login.html?redirect=/admin.html';
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const { user } = await UaDeckApi.getMe();
|
||||
if (user.role !== 'admin') {
|
||||
alert('관리자 권한이 필요합니다.');
|
||||
location.href = '/';
|
||||
return false;
|
||||
}
|
||||
currentAdminId = user.id;
|
||||
return true;
|
||||
} catch {
|
||||
location.href = '/login.html?redirect=/admin.html';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers(q = '') {
|
||||
const { users, total } = await UaDeckApi.adminGetUsers(q);
|
||||
const list = document.getElementById('user-list');
|
||||
list.innerHTML =
|
||||
`<p class="admin-count">총 ${total}명</p>` +
|
||||
users
|
||||
.map((u) => {
|
||||
const isSelf = u.id === currentAdminId;
|
||||
return `
|
||||
<article class="admin-card">
|
||||
<div class="admin-user-card">
|
||||
<img src="${escapeHtml(resolveAvatarSrc(u))}" alt="" class="admin-user-avatar" onerror="this.src='${DEFAULT_AVATAR}'" />
|
||||
<div class="admin-user-body">
|
||||
<h3 class="admin-card-title">${escapeHtml(u.username)}${isSelf ? ' <span class="admin-badge admin-badge-type">나</span>' : ''}</h3>
|
||||
<div class="admin-card-meta">${escapeHtml(u.email)}</div>
|
||||
<div class="admin-card-badges">
|
||||
<span class="admin-badge admin-badge-type">${escapeHtml(u.role)}</span>
|
||||
<span class="admin-badge ${u.isActive ? 'admin-badge-public' : 'admin-badge-private'}">${u.isActive ? '활성' : '비활성'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" data-edit='${JSON.stringify(u).replace(/'/g, ''')}' class="admin-card-btn admin-card-btn-edit">수정</button>
|
||||
</div>
|
||||
</article>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
list.querySelectorAll('[data-edit]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => openEditModal(JSON.parse(btn.dataset.edit)));
|
||||
});
|
||||
}
|
||||
|
||||
function openEditModal(u) {
|
||||
editingUser = u;
|
||||
pendingAvatarImage = null;
|
||||
pendingRemoveAvatar = false;
|
||||
const isSelf = u.id === currentAdminId;
|
||||
|
||||
document.getElementById('edit-user-id').value = u.id;
|
||||
document.getElementById('edit-email').value = u.email;
|
||||
document.getElementById('edit-username').value = u.username;
|
||||
document.getElementById('edit-role').value = u.role;
|
||||
document.getElementById('edit-isActive').value = u.isActive ? '1' : '0';
|
||||
document.getElementById('edit-modal-title').textContent = u.username;
|
||||
document.getElementById('edit-modal-sub').textContent = u.email;
|
||||
document.getElementById('edit-avatar-preview').src = resolveAvatarSrc(u);
|
||||
document.getElementById('edit-avatar-remove').classList.toggle('hidden', !u.avatarUrl);
|
||||
|
||||
document.getElementById('edit-role').disabled = isSelf;
|
||||
document.getElementById('edit-isActive').disabled = isSelf;
|
||||
document.getElementById('edit-role-hint').classList.toggle('hidden', !isSelf);
|
||||
document.getElementById('edit-status-hint').classList.toggle('hidden', !isSelf);
|
||||
document.getElementById('modal-error').classList.add('hidden');
|
||||
|
||||
editModal.classList.add('open');
|
||||
}
|
||||
|
||||
function renderDeckPreview(d) {
|
||||
if (!d.preview_url) {
|
||||
return '<div class="admin-deck-preview-placeholder">미리보기 없음</div>';
|
||||
}
|
||||
const src = UaDeckApi.resolveMediaUrl(d.preview_url);
|
||||
const version = d.updatedAt ? `?v=${encodeURIComponent(d.updatedAt)}` : '';
|
||||
return `<img src="${escapeHtml(src + version)}" alt="" loading="lazy" onerror="this.parentElement.innerHTML='<div class=\\'admin-deck-preview-placeholder\\'>미리보기 없음</div>'" />`;
|
||||
}
|
||||
|
||||
async function loadAdminDecks() {
|
||||
const { decks } = await UaDeckApi.adminGetDecks();
|
||||
document.getElementById('deck-count').textContent = `총 ${decks.length}개`;
|
||||
document.getElementById('admin-deck-list').innerHTML = decks
|
||||
.map((d) => {
|
||||
const publicBadge = d.isPublic
|
||||
? '<span class="admin-badge admin-badge-public">공개</span>'
|
||||
: '<span class="admin-badge admin-badge-private">비공개</span>';
|
||||
return `
|
||||
<article class="admin-card admin-deck-card">
|
||||
<div class="admin-deck-preview">${renderDeckPreview(d)}</div>
|
||||
<div class="admin-deck-body">
|
||||
<h3 class="admin-card-title">${escapeHtml(d.name || d.seriesName || d.series)}</h3>
|
||||
<div class="admin-card-meta">@${escapeHtml(d.username || '비회원')} · 조회 ${d.viewCount}</div>
|
||||
<div class="admin-card-meta">/d/${escapeHtml(d.shareCode)}</div>
|
||||
<div class="admin-card-badges">
|
||||
<span class="admin-badge admin-badge-type">${escapeHtml(d.seriesName || d.series)}</span>
|
||||
${publicBadge}
|
||||
</div>
|
||||
<div class="admin-card-actions" style="margin-top:0.5rem">
|
||||
<button type="button" data-del="${escapeHtml(d.shareCode)}" class="admin-card-btn admin-card-btn-delete">삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
document.querySelectorAll('[data-del]').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!confirm('삭제하시겠습니까?')) return;
|
||||
await UaDeckApi.adminDeleteDeck(btn.dataset.del);
|
||||
loadAdminDecks();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('edit-avatar-btn').addEventListener('click', () => {
|
||||
document.getElementById('edit-avatar-file').click();
|
||||
});
|
||||
|
||||
document.getElementById('edit-avatar-file').addEventListener('change', async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
pendingAvatarImage = await UaImageUtils.resizeImageToPng(file);
|
||||
pendingRemoveAvatar = false;
|
||||
document.getElementById('edit-avatar-preview').src = pendingAvatarImage;
|
||||
document.getElementById('edit-avatar-remove').classList.remove('hidden');
|
||||
} catch (err) {
|
||||
alert(err.message || '이미지 처리에 실패했습니다.');
|
||||
}
|
||||
e.target.value = '';
|
||||
});
|
||||
|
||||
document.getElementById('edit-avatar-remove').addEventListener('click', () => {
|
||||
pendingAvatarImage = null;
|
||||
pendingRemoveAvatar = true;
|
||||
document.getElementById('edit-avatar-preview').src = DEFAULT_AVATAR;
|
||||
document.getElementById('edit-avatar-remove').classList.add('hidden');
|
||||
});
|
||||
|
||||
document.getElementById('open-password-modal').addEventListener('click', () => {
|
||||
if (!editingUser) return;
|
||||
document.getElementById('password-user-id').value = editingUser.id;
|
||||
document.getElementById('password-modal-sub').textContent = `${editingUser.username} (${editingUser.email})`;
|
||||
document.getElementById('password-new').value = '';
|
||||
document.getElementById('password-confirm').value = '';
|
||||
document.getElementById('password-error').classList.add('hidden');
|
||||
passwordModal.classList.add('open');
|
||||
});
|
||||
|
||||
document.getElementById('password-cancel').addEventListener('click', () => {
|
||||
passwordModal.classList.remove('open');
|
||||
});
|
||||
|
||||
document.getElementById('password-save').addEventListener('click', async () => {
|
||||
const errEl = document.getElementById('password-error');
|
||||
errEl.classList.add('hidden');
|
||||
const pw = document.getElementById('password-new').value;
|
||||
const confirm = document.getElementById('password-confirm').value;
|
||||
if (pw.length < 8) {
|
||||
errEl.textContent = '비밀번호는 8자 이상이어야 합니다.';
|
||||
errEl.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
if (pw !== confirm) {
|
||||
errEl.textContent = '비밀번호가 일치하지 않습니다.';
|
||||
errEl.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await UaDeckApi.adminResetPassword(document.getElementById('password-user-id').value, pw);
|
||||
passwordModal.classList.remove('open');
|
||||
alert('비밀번호가 변경되었습니다.');
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message;
|
||||
errEl.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('.admin-tab').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.admin-tab').forEach((b) => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
document.getElementById('tab-users').classList.toggle('hidden', btn.dataset.tab !== 'users');
|
||||
document.getElementById('tab-decks').classList.toggle('hidden', btn.dataset.tab !== 'decks');
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('user-search-btn').addEventListener('click', () => {
|
||||
loadUsers(document.getElementById('user-search').value);
|
||||
});
|
||||
|
||||
document.getElementById('modal-cancel').addEventListener('click', () => {
|
||||
editModal.classList.remove('open');
|
||||
});
|
||||
|
||||
document.getElementById('modal-save').addEventListener('click', async () => {
|
||||
const errEl = document.getElementById('modal-error');
|
||||
errEl.classList.add('hidden');
|
||||
const id = document.getElementById('edit-user-id').value;
|
||||
const isSelf = Number(id) === currentAdminId;
|
||||
const body = {
|
||||
email: document.getElementById('edit-email').value,
|
||||
username: document.getElementById('edit-username').value,
|
||||
};
|
||||
if (!isSelf) {
|
||||
body.role = document.getElementById('edit-role').value;
|
||||
body.isActive = document.getElementById('edit-isActive').value === '1';
|
||||
}
|
||||
if (pendingAvatarImage) body.avatarImage = pendingAvatarImage;
|
||||
if (pendingRemoveAvatar) body.removeAvatar = true;
|
||||
|
||||
try {
|
||||
await UaDeckApi.adminUpdateUser(id, body);
|
||||
editModal.classList.remove('open');
|
||||
loadUsers(document.getElementById('user-search').value);
|
||||
if (isSelf && window.UaDeckAuth?.refresh) {
|
||||
await window.UaDeckAuth.refresh();
|
||||
}
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message;
|
||||
errEl.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
[editModal, passwordModal].forEach((modal) => {
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) modal.classList.remove('open');
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
if (await guardAdmin()) {
|
||||
loadUsers();
|
||||
loadAdminDecks();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
3
api/.gitignore
vendored
Normal file
3
api/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
data/
|
||||
.env
|
||||
18
api/Dockerfile
Normal file
18
api/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY src ./src
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV DATAS_PATH=/datas
|
||||
ENV DB_PATH=/data/uadeck.db
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "src/index.js"]
|
||||
18
api/package.json
Normal file
18
api/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "uadeck-api",
|
||||
"version": "4.0.0",
|
||||
"type": "module",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "node --watch src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.2",
|
||||
"better-sqlite3": "^11.9.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.5.0",
|
||||
"express": "^5.1.0",
|
||||
"jsonwebtoken": "^9.0.2"
|
||||
}
|
||||
}
|
||||
102
api/src/db.js
Normal file
102
api/src/db.js
Normal file
@@ -0,0 +1,102 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* SQLite DB 연결 및 스키마 초기화
|
||||
* @param {string} dbPath
|
||||
* @returns {import('better-sqlite3').Database}
|
||||
*/
|
||||
export function initDb(dbPath) {
|
||||
const dir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const db = new Database(dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
avatar_url TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
share_code TEXT NOT NULL UNIQUE,
|
||||
share_type TEXT NOT NULL DEFAULT 'user',
|
||||
content_hash TEXT,
|
||||
user_id INTEGER,
|
||||
series TEXT NOT NULL,
|
||||
series_name TEXT,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
cards_json TEXT NOT NULL,
|
||||
is_public INTEGER NOT NULL DEFAULT 1,
|
||||
view_count INTEGER NOT NULL DEFAULT 0,
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
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);
|
||||
`);
|
||||
|
||||
const deckCols = db.prepare('PRAGMA table_info(decks)').all();
|
||||
if (!deckCols.some((c) => c.name === 'preview_image')) {
|
||||
db.exec('ALTER TABLE decks ADD COLUMN preview_image TEXT');
|
||||
}
|
||||
|
||||
const userCols = db.prepare('PRAGMA table_info(users)').all();
|
||||
if (!userCols.some((c) => c.name === 'default_deck_public')) {
|
||||
db.exec('ALTER TABLE users ADD COLUMN default_deck_public INTEGER NOT NULL DEFAULT 1');
|
||||
}
|
||||
if (!userCols.some((c) => c.name === 'skip_deck_public_prompt')) {
|
||||
db.exec('ALTER TABLE users ADD COLUMN skip_deck_public_prompt INTEGER NOT NULL DEFAULT 0');
|
||||
}
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
/**
|
||||
* 환경변수 기준 관리자 계정 시드
|
||||
* ADMIN_EMAIL로 이미 가입한 경우 역할만 admin으로 승격한다.
|
||||
* @param {import('better-sqlite3').Database} db
|
||||
*/
|
||||
export function seedAdmin(db) {
|
||||
const email = process.env.ADMIN_EMAIL?.trim().toLowerCase();
|
||||
const password = process.env.ADMIN_PASSWORD;
|
||||
const username = process.env.ADMIN_USERNAME || 'admin';
|
||||
|
||||
if (!email || !password) return;
|
||||
|
||||
const existingAdmin = db.prepare('SELECT id FROM users WHERE role = ?').get('admin');
|
||||
if (existingAdmin) return;
|
||||
|
||||
const existingUser = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
|
||||
if (existingUser) {
|
||||
db.prepare(`
|
||||
UPDATE users SET role = 'admin', updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`).run(existingUser.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const hash = bcrypt.hashSync(password, 12);
|
||||
db.prepare(`
|
||||
INSERT INTO users (email, username, password_hash, display_name, role)
|
||||
VALUES (?, ?, ?, ?, 'admin')
|
||||
`).run(email, username, hash, '관리자');
|
||||
}
|
||||
48
api/src/index.js
Normal file
48
api/src/index.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { initDb, seedAdmin } from './db.js';
|
||||
import { createAuthRouter } from './routes/auth.js';
|
||||
import { createDecksRouter } from './routes/decks.js';
|
||||
import { createAdminRouter } from './routes/admin.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PORT = Number(process.env.PORT) || 3000;
|
||||
const DB_PATH = process.env.DB_PATH || path.join(__dirname, '..', 'data', 'uadeck.db');
|
||||
|
||||
if (!process.env.DATAS_PATH) {
|
||||
process.env.DATAS_PATH = path.join(__dirname, '..', '..', 'datas');
|
||||
}
|
||||
|
||||
const db = initDb(DB_PATH);
|
||||
seedAdmin(db);
|
||||
|
||||
const app = express();
|
||||
|
||||
const corsOrigins = process.env.CORS_ORIGINS
|
||||
? process.env.CORS_ORIGINS.split(',').map((o) => o.trim())
|
||||
: true;
|
||||
|
||||
app.use(cors({ origin: corsOrigins, credentials: true }));
|
||||
app.use(express.json({ limit: '15mb' }));
|
||||
|
||||
app.get('/api/v1/health', (_req, res) => {
|
||||
res.json({ ok: true, version: '4.0.0' });
|
||||
});
|
||||
|
||||
app.use('/api/v1/auth', createAuthRouter(db));
|
||||
app.use('/api/v1/decks', createDecksRouter(db));
|
||||
app.use('/api/v1/admin', createAdminRouter(db));
|
||||
|
||||
app.use((err, _req, res, _next) => {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: '서버 오류가 발생했습니다.' });
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`UADECK API v4.0.0 listening on :${PORT}`);
|
||||
console.log(`DB: ${DB_PATH}`);
|
||||
console.log(`DATAS: ${process.env.DATAS_PATH || '(default ../datas)'}`);
|
||||
});
|
||||
91
api/src/middleware/auth.js
Normal file
91
api/src/middleware/auth.js
Normal file
@@ -0,0 +1,91 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
/**
|
||||
* JWT 시크릿
|
||||
* @returns {string}
|
||||
*/
|
||||
function getSecret() {
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error('JWT_SECRET 환경변수가 설정되지 않았습니다.');
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 토큰 발급
|
||||
* @param {object} user
|
||||
* @returns {string}
|
||||
*/
|
||||
export function signToken(user) {
|
||||
return jwt.sign(
|
||||
{ sub: user.id, role: user.role },
|
||||
getSecret(),
|
||||
{ expiresIn: process.env.JWT_EXPIRES || '7d' },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택적 인증 — 토큰 없으면 req.user = null
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
* @param {import('express').NextFunction} next
|
||||
*/
|
||||
export function optionalAuth(req, res, next) {
|
||||
const header = req.headers.authorization;
|
||||
if (!header?.startsWith('Bearer ')) {
|
||||
req.user = null;
|
||||
return next();
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = jwt.verify(header.slice(7), getSecret());
|
||||
req.user = { id: payload.sub, role: payload.role };
|
||||
} catch {
|
||||
req.user = null;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* 필수 인증
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
* @param {import('express').NextFunction} next
|
||||
*/
|
||||
export function requireAuth(req, res, next) {
|
||||
optionalAuth(req, res, () => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: '로그인이 필요합니다.' });
|
||||
}
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 전용 — JWT가 아닌 DB의 최신 role을 확인한다.
|
||||
* @param {import('better-sqlite3').Database} db
|
||||
* @returns {import('express').RequestHandler}
|
||||
*/
|
||||
export function createRequireAdmin(db) {
|
||||
return (req, res, next) => {
|
||||
requireAuth(req, res, () => {
|
||||
const row = db.prepare('SELECT role FROM users WHERE id = ? AND is_active = 1').get(req.user.id);
|
||||
if (!row || row.role !== 'admin') {
|
||||
return res.status(403).json({ error: '관리자 권한이 필요합니다.' });
|
||||
}
|
||||
req.user.role = row.role;
|
||||
next();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated createRequireAdmin(db) 사용 권장
|
||||
*/
|
||||
export function requireAdmin(req, res, next) {
|
||||
if (req.user?.role !== 'admin') {
|
||||
return res.status(403).json({ error: '관리자 권한이 필요합니다.' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
250
api/src/routes/admin.js
Normal file
250
api/src/routes/admin.js
Normal file
@@ -0,0 +1,250 @@
|
||||
import { Router } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { createRequireAdmin } from '../middleware/auth.js';
|
||||
import { contentHash, validateCards, nowIso } from '../utils.js';
|
||||
import { resolveDeckCards } from '../services/cardResolver.js';
|
||||
import { getPreviewUrl } from '../services/previewImage.js';
|
||||
import { getAvatarUrl, saveAvatarImage, deleteAvatarImage } from '../services/avatarImage.js';
|
||||
|
||||
/**
|
||||
* 관리자 라우트
|
||||
* @param {import('better-sqlite3').Database} db
|
||||
* @returns {import('express').Router}
|
||||
*/
|
||||
export function createAdminRouter(db) {
|
||||
const router = Router();
|
||||
router.use(createRequireAdmin(db));
|
||||
|
||||
router.get('/users', (req, res) => {
|
||||
const { q, page = 1, limit = 20 } = req.query;
|
||||
const offset = (Number(page) - 1) * Number(limit);
|
||||
let rows;
|
||||
let total;
|
||||
|
||||
if (q) {
|
||||
const like = `%${q}%`;
|
||||
total = db.prepare(`
|
||||
SELECT COUNT(*) AS c FROM users
|
||||
WHERE email LIKE ? OR username LIKE ? OR display_name LIKE ?
|
||||
`).get(like, like, like).c;
|
||||
rows = db.prepare(`
|
||||
SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl,
|
||||
role, is_active AS isActive, created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM users
|
||||
WHERE email LIKE ? OR username LIKE ? OR display_name LIKE ?
|
||||
ORDER BY created_at DESC LIMIT ? OFFSET ?
|
||||
`).all(like, like, like, Number(limit), offset);
|
||||
} else {
|
||||
total = db.prepare('SELECT COUNT(*) AS c FROM users').get().c;
|
||||
rows = db.prepare(`
|
||||
SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl,
|
||||
role, is_active AS isActive, created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM users ORDER BY created_at DESC LIMIT ? OFFSET ?
|
||||
`).all(Number(limit), offset);
|
||||
}
|
||||
|
||||
res.json({
|
||||
users: rows.map((u) => ({
|
||||
...u,
|
||||
avatarUrl: getAvatarUrl(u.id) || u.avatarUrl,
|
||||
})),
|
||||
total,
|
||||
page: Number(page),
|
||||
limit: Number(limit),
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/users/:id', (req, res) => {
|
||||
const user = db.prepare(`
|
||||
SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl,
|
||||
role, is_active AS isActive, created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM users WHERE id = ?
|
||||
`).get(req.params.id);
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
const deckCount = db.prepare('SELECT COUNT(*) AS c FROM decks WHERE user_id = ?').get(user.id).c;
|
||||
user.avatarUrl = getAvatarUrl(user.id) || user.avatarUrl;
|
||||
res.json({ user, deckCount });
|
||||
});
|
||||
|
||||
router.put('/users/:id', (req, res) => {
|
||||
const targetId = Number(req.params.id);
|
||||
const user = db.prepare('SELECT id, role FROM users WHERE id = ?').get(targetId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
const isSelf = req.user.id === targetId;
|
||||
const { email, username, role, isActive, avatarImage, removeAvatar } = req.body || {};
|
||||
|
||||
if (isSelf) {
|
||||
if (role === 'user') {
|
||||
return res.status(400).json({ error: '자신의 관리자 권한은 해제할 수 없습니다.' });
|
||||
}
|
||||
if (isActive === false) {
|
||||
return res.status(400).json({ error: '자신의 계정은 비활성화할 수 없습니다.' });
|
||||
}
|
||||
}
|
||||
|
||||
let nextAvatarUrl;
|
||||
if (avatarImage) {
|
||||
const saved = saveAvatarImage(targetId, avatarImage);
|
||||
if (!saved) {
|
||||
return res.status(400).json({ error: '프로필 이미지 저장에 실패했습니다.' });
|
||||
}
|
||||
nextAvatarUrl = getAvatarUrl(targetId);
|
||||
} else if (removeAvatar === true) {
|
||||
deleteAvatarImage(targetId);
|
||||
nextAvatarUrl = null;
|
||||
}
|
||||
|
||||
const trimmedUsername = username?.trim() ?? null;
|
||||
const trimmedEmail = email?.trim().toLowerCase() ?? null;
|
||||
|
||||
try {
|
||||
const fields = ['updated_at = ?'];
|
||||
const values = [nowIso()];
|
||||
|
||||
if (trimmedEmail) {
|
||||
fields.push('email = ?');
|
||||
values.push(trimmedEmail);
|
||||
}
|
||||
if (trimmedUsername) {
|
||||
fields.push('username = ?', 'display_name = ?');
|
||||
values.push(trimmedUsername, trimmedUsername);
|
||||
}
|
||||
if (!isSelf && role) {
|
||||
fields.push('role = ?');
|
||||
values.push(role);
|
||||
}
|
||||
if (!isSelf && isActive !== undefined) {
|
||||
fields.push('is_active = ?');
|
||||
values.push(isActive ? 1 : 0);
|
||||
}
|
||||
if (nextAvatarUrl !== undefined) {
|
||||
fields.push('avatar_url = ?');
|
||||
values.push(nextAvatarUrl);
|
||||
}
|
||||
|
||||
values.push(targetId);
|
||||
db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
||||
} catch (err) {
|
||||
if (String(err.message).includes('UNIQUE')) {
|
||||
return res.status(409).json({ error: '이메일 또는 사용자명이 이미 사용 중입니다.' });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const updated = db.prepare(`
|
||||
SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl,
|
||||
role, is_active AS isActive, created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM users WHERE id = ?
|
||||
`).get(targetId);
|
||||
updated.avatarUrl = getAvatarUrl(updated.id) || updated.avatarUrl;
|
||||
|
||||
res.json({ user: updated });
|
||||
});
|
||||
|
||||
router.post('/users/:id/reset-password', (req, res) => {
|
||||
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
const { newPassword } = req.body || {};
|
||||
if (!newPassword || newPassword.length < 8) {
|
||||
return res.status(400).json({ error: '새 비밀번호는 8자 이상이어야 합니다.' });
|
||||
}
|
||||
|
||||
const hash = bcrypt.hashSync(newPassword, 12);
|
||||
db.prepare('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?').run(hash, nowIso(), req.params.id);
|
||||
|
||||
res.json({ ok: true, message: '비밀번호가 초기화되었습니다.' });
|
||||
});
|
||||
|
||||
router.post('/decks/preset', async (req, res, next) => {
|
||||
try {
|
||||
const { series, seriesName, name, cards, isPublic } = req.body || {};
|
||||
if (!series || !cards || !name) {
|
||||
return res.status(400).json({ error: 'series, name, cards는 필수입니다.' });
|
||||
}
|
||||
|
||||
const normalized = validateCards(cards);
|
||||
const hash = contentHash(normalized);
|
||||
const existing = db.prepare('SELECT share_code FROM decks WHERE content_hash = ? AND share_type = ?').get(hash, 'preset');
|
||||
|
||||
if (existing) {
|
||||
return res.status(409).json({
|
||||
error: '동일한 구성의 프리셋이 이미 존재합니다.',
|
||||
shareCode: existing.share_code,
|
||||
});
|
||||
}
|
||||
|
||||
const cardsJson = JSON.stringify(normalized);
|
||||
db.prepare(`
|
||||
INSERT INTO decks (share_code, share_type, content_hash, user_id, series, series_name, name, cards_json, is_public)
|
||||
VALUES (?, 'preset', ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
hash,
|
||||
hash,
|
||||
req.user.id,
|
||||
series,
|
||||
seriesName || series,
|
||||
name,
|
||||
cardsJson,
|
||||
isPublic === false ? 0 : 1,
|
||||
);
|
||||
|
||||
const row = db.prepare('SELECT * FROM decks WHERE share_code = ?').get(hash);
|
||||
const { cards: resolved, totalCards } = await resolveDeckCards(series, normalized);
|
||||
|
||||
res.status(201).json({
|
||||
v: 1,
|
||||
shareCode: row.share_code,
|
||||
shareType: 'preset',
|
||||
series: row.series,
|
||||
seriesName: row.series_name,
|
||||
name: row.name,
|
||||
totalCards,
|
||||
cards: resolved,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.message?.includes('카드') || err.message?.includes('덱')) {
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/decks', (req, res) => {
|
||||
const rows = db.prepare(`
|
||||
SELECT d.id, d.share_code AS shareCode, d.share_type AS shareType, d.series, d.series_name AS seriesName,
|
||||
d.name, d.is_public AS isPublic, d.view_count AS viewCount, d.created_at AS createdAt,
|
||||
d.preview_image AS previewImage, d.updated_at AS updatedAt,
|
||||
u.username, u.id AS userId
|
||||
FROM decks d LEFT JOIN users u ON d.user_id = u.id
|
||||
ORDER BY d.created_at DESC LIMIT 100
|
||||
`).all();
|
||||
|
||||
res.json({
|
||||
decks: rows.map((row) => ({
|
||||
...row,
|
||||
preview_url: getPreviewUrl(row.shareCode, row.previewImage),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
router.delete('/decks/:shareCode', (req, res) => {
|
||||
const row = db.prepare('SELECT id FROM decks WHERE share_code = ?').get(req.params.shareCode);
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: '덱을 찾을 수 없습니다.' });
|
||||
}
|
||||
db.prepare('DELETE FROM decks WHERE id = ?').run(row.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
212
api/src/routes/auth.js
Normal file
212
api/src/routes/auth.js
Normal file
@@ -0,0 +1,212 @@
|
||||
import { Router } from 'express';
|
||||
import path from 'path';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { signToken, requireAuth } from '../middleware/auth.js';
|
||||
import { nowIso } from '../utils.js';
|
||||
import { getAvatarFilePath, getAvatarUrl, saveAvatarImage, deleteAvatarImage } from '../services/avatarImage.js';
|
||||
|
||||
/**
|
||||
* DB 사용자 행을 API 응답 형식으로 변환
|
||||
* @param {object} user
|
||||
* @returns {object}
|
||||
*/
|
||||
function toUserResponse(user) {
|
||||
if (!user) return null;
|
||||
return {
|
||||
...user,
|
||||
displayName: user.username,
|
||||
avatarUrl: getAvatarUrl(user.id) || user.avatarUrl || null,
|
||||
defaultDeckPublic: (user.default_deck_public ?? user.defaultDeckPublic ?? 1) !== 0,
|
||||
skipDeckPublicPrompt: Boolean(user.skip_deck_public_prompt ?? user.skipDeckPublicPrompt),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 라우트
|
||||
* @param {import('better-sqlite3').Database} db
|
||||
* @returns {import('express').Router}
|
||||
*/
|
||||
export function createAuthRouter(db) {
|
||||
const router = Router();
|
||||
|
||||
router.get('/avatar/:userId', (req, res) => {
|
||||
const filePath = getAvatarFilePath(req.params.userId);
|
||||
if (!filePath) {
|
||||
return res.status(404).json({ error: '아바타가 없습니다.' });
|
||||
}
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
res.sendFile(path.resolve(filePath), (err) => {
|
||||
if (err && !res.headersSent) {
|
||||
res.status(404).json({ error: '아바타가 없습니다.' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/register', (req, res) => {
|
||||
const { email, username, password } = req.body || {};
|
||||
|
||||
if (!email || !username || !password) {
|
||||
return res.status(400).json({ error: '이메일, 사용자명, 비밀번호는 필수입니다.' });
|
||||
}
|
||||
if (password.length < 8) {
|
||||
return res.status(400).json({ error: '비밀번호는 8자 이상이어야 합니다.' });
|
||||
}
|
||||
|
||||
const hash = bcrypt.hashSync(password, 12);
|
||||
|
||||
try {
|
||||
const trimmedUsername = username.trim();
|
||||
const result = db.prepare(`
|
||||
INSERT INTO users (email, username, password_hash, display_name)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(email.trim().toLowerCase(), trimmedUsername, hash, trimmedUsername);
|
||||
|
||||
const user = db.prepare(`
|
||||
SELECT id, email, username, role, created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM users WHERE id = ?
|
||||
`).get(result.lastInsertRowid);
|
||||
|
||||
const safeUser = toUserResponse(user);
|
||||
const token = signToken(safeUser);
|
||||
res.status(201).json({ token, user: safeUser });
|
||||
} catch (err) {
|
||||
if (String(err.message).includes('UNIQUE')) {
|
||||
return res.status(409).json({ error: '이미 사용 중인 이메일 또는 사용자명입니다.' });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/login', (req, res) => {
|
||||
const { email, password } = req.body || {};
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ error: '이메일과 비밀번호를 입력하세요.' });
|
||||
}
|
||||
|
||||
const user = db.prepare(`
|
||||
SELECT id, email, username, password_hash, display_name, avatar_url, role, is_active
|
||||
FROM users WHERE email = ?
|
||||
`).get(email.trim().toLowerCase());
|
||||
|
||||
if (!user || !user.is_active) {
|
||||
return res.status(401).json({ error: '이메일 또는 비밀번호가 올바르지 않습니다.' });
|
||||
}
|
||||
|
||||
if (!bcrypt.compareSync(password, user.password_hash)) {
|
||||
return res.status(401).json({ error: '이메일 또는 비밀번호가 올바르지 않습니다.' });
|
||||
}
|
||||
|
||||
const row = db.prepare(`
|
||||
SELECT id, email, username, avatar_url, role, updated_at AS updatedAt
|
||||
FROM users WHERE id = ?
|
||||
`).get(user.id);
|
||||
|
||||
const safeUser = toUserResponse(row);
|
||||
res.json({ token: signToken(safeUser), user: safeUser });
|
||||
});
|
||||
|
||||
router.get('/me', requireAuth, (req, res) => {
|
||||
const user = db.prepare(`
|
||||
SELECT id, email, username, avatar_url AS avatarUrl, role,
|
||||
default_deck_public AS defaultDeckPublic,
|
||||
skip_deck_public_prompt AS skipDeckPublicPrompt,
|
||||
created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM users WHERE id = ? AND is_active = 1
|
||||
`).get(req.user.id);
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
res.json({ user: toUserResponse(user) });
|
||||
});
|
||||
|
||||
router.put('/me', requireAuth, (req, res) => {
|
||||
const { email, username, avatarImage, removeAvatar, defaultDeckPublic, skipDeckPublicPrompt } =
|
||||
req.body || {};
|
||||
const fields = ['updated_at = ?'];
|
||||
const values = [nowIso()];
|
||||
|
||||
const trimmedEmail = email?.trim().toLowerCase();
|
||||
const trimmedUsername = username?.trim();
|
||||
|
||||
if (trimmedEmail) {
|
||||
fields.push('email = ?');
|
||||
values.push(trimmedEmail);
|
||||
}
|
||||
if (trimmedUsername) {
|
||||
fields.push('username = ?', 'display_name = ?');
|
||||
values.push(trimmedUsername, trimmedUsername);
|
||||
}
|
||||
|
||||
if (avatarImage) {
|
||||
const saved = saveAvatarImage(req.user.id, avatarImage);
|
||||
if (!saved) {
|
||||
return res.status(400).json({ error: '프로필 이미지 저장에 실패했습니다.' });
|
||||
}
|
||||
const avatarUrl = getAvatarUrl(req.user.id);
|
||||
fields.push('avatar_url = ?');
|
||||
values.push(avatarUrl);
|
||||
} else if (removeAvatar === true) {
|
||||
deleteAvatarImage(req.user.id);
|
||||
fields.push('avatar_url = ?');
|
||||
values.push(null);
|
||||
}
|
||||
|
||||
if (typeof defaultDeckPublic === 'boolean') {
|
||||
fields.push('default_deck_public = ?');
|
||||
values.push(defaultDeckPublic ? 1 : 0);
|
||||
}
|
||||
if (typeof skipDeckPublicPrompt === 'boolean') {
|
||||
fields.push('skip_deck_public_prompt = ?');
|
||||
values.push(skipDeckPublicPrompt ? 1 : 0);
|
||||
}
|
||||
|
||||
try {
|
||||
values.push(req.user.id);
|
||||
db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
||||
} catch (err) {
|
||||
if (String(err.message).includes('UNIQUE')) {
|
||||
return res.status(409).json({ error: '이미 사용 중인 이메일 또는 사용자명입니다.' });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const user = db.prepare(`
|
||||
SELECT id, email, username, avatar_url AS avatarUrl, role,
|
||||
default_deck_public AS defaultDeckPublic,
|
||||
skip_deck_public_prompt AS skipDeckPublicPrompt,
|
||||
created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM users WHERE id = ?
|
||||
`).get(req.user.id);
|
||||
|
||||
res.json({ user: toUserResponse(user) });
|
||||
});
|
||||
|
||||
router.post('/me/password', requireAuth, (req, res) => {
|
||||
const { currentPassword, newPassword } = req.body || {};
|
||||
if (!currentPassword || !newPassword) {
|
||||
return res.status(400).json({ error: '현재 비밀번호와 새 비밀번호를 입력하세요.' });
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
return res.status(400).json({ error: '새 비밀번호는 8자 이상이어야 합니다.' });
|
||||
}
|
||||
|
||||
const row = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.user.id);
|
||||
if (!bcrypt.compareSync(currentPassword, row.password_hash)) {
|
||||
return res.status(401).json({ error: '현재 비밀번호가 올바르지 않습니다.' });
|
||||
}
|
||||
|
||||
const hash = bcrypt.hashSync(newPassword, 12);
|
||||
db.prepare('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?').run(
|
||||
hash,
|
||||
nowIso(),
|
||||
req.user.id,
|
||||
);
|
||||
|
||||
res.json({ ok: true, message: '비밀번호가 변경되었습니다.' });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
290
api/src/routes/decks.js
Normal file
290
api/src/routes/decks.js
Normal file
@@ -0,0 +1,290 @@
|
||||
import { Router } from 'express';
|
||||
import path from 'path';
|
||||
import { requireAuth, optionalAuth } from '../middleware/auth.js';
|
||||
import { resolveDeckCards } from '../services/cardResolver.js';
|
||||
import { generateShareCode, validateCards, nowIso } from '../utils.js';
|
||||
import { savePreviewImage, getPreviewFilePath, getPreviewUrl } from '../services/previewImage.js';
|
||||
|
||||
/**
|
||||
* 덱 행을 API 응답 형식으로 변환
|
||||
* @param {object} row
|
||||
* @param {object|null} author
|
||||
* @param {object[]} resolvedCards
|
||||
* @param {number} totalCards
|
||||
* @returns {object}
|
||||
*/
|
||||
function toDeckResponse(row, author, resolvedCards, totalCards) {
|
||||
return {
|
||||
v: 1,
|
||||
shareCode: row.share_code,
|
||||
shareType: row.share_type,
|
||||
series: row.series,
|
||||
seriesName: row.series_name,
|
||||
name: row.name,
|
||||
totalCards,
|
||||
cards: resolvedCards,
|
||||
author,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
viewCount: row.view_count,
|
||||
isPublic: Boolean(row.is_public),
|
||||
expiresAt: row.expires_at,
|
||||
previewUrl: getPreviewUrl(row.share_code, row.preview_image),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 작성자 정보 조회
|
||||
* @param {import('better-sqlite3').Database} db
|
||||
* @param {number|null} userId
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function getAuthor(db, userId) {
|
||||
if (!userId) return null;
|
||||
const user = db.prepare(`
|
||||
SELECT id, username, avatar_url AS avatarUrl
|
||||
FROM users WHERE id = ? AND is_active = 1
|
||||
`).get(userId);
|
||||
if (!user) return null;
|
||||
return { ...user, displayName: user.username };
|
||||
}
|
||||
|
||||
/**
|
||||
* 덱 라우트
|
||||
* @param {import('better-sqlite3').Database} db
|
||||
* @returns {import('express').Router}
|
||||
*/
|
||||
export function createDecksRouter(db) {
|
||||
const router = Router();
|
||||
|
||||
router.get('/presets', async (_req, res, next) => {
|
||||
try {
|
||||
const rows = db.prepare(`
|
||||
SELECT d.share_code, d.series, d.series_name, d.name, d.view_count,
|
||||
d.preview_image, d.created_at, d.updated_at,
|
||||
u.display_name, u.username
|
||||
FROM decks d
|
||||
LEFT JOIN users u ON d.user_id = u.id
|
||||
WHERE d.share_type = 'user' AND d.is_public = 1
|
||||
AND (d.expires_at IS NULL OR d.expires_at > datetime('now'))
|
||||
ORDER BY d.updated_at DESC
|
||||
`).all();
|
||||
|
||||
res.json({
|
||||
presets: rows.map((row) => ({
|
||||
share_code: row.share_code,
|
||||
series: row.series,
|
||||
series_name: row.series_name,
|
||||
name: row.name,
|
||||
view_count: row.view_count,
|
||||
author: row.username || '익명',
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
preview_url: getPreviewUrl(row.share_code, row.preview_image),
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:shareCode/preview', (req, res) => {
|
||||
const filePath = getPreviewFilePath(req.params.shareCode);
|
||||
if (!filePath) {
|
||||
return res.status(404).json({ error: '미리보기 이미지가 없습니다.' });
|
||||
}
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
res.sendFile(path.resolve(filePath), (err) => {
|
||||
if (err && !res.headersSent) {
|
||||
res.status(404).json({ error: '미리보기 이미지가 없습니다.' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/:shareCode', optionalAuth, async (req, res, next) => {
|
||||
try {
|
||||
const row = db.prepare('SELECT * FROM decks WHERE share_code = ?').get(req.params.shareCode);
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: '덱을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
if (row.expires_at && new Date(row.expires_at) < new Date()) {
|
||||
return res.status(410).json({ error: '만료된 공유 링크입니다.' });
|
||||
}
|
||||
|
||||
const isOwner = req.user && row.user_id === req.user.id;
|
||||
const isAdmin = req.user?.role === 'admin';
|
||||
|
||||
if (!row.is_public && !isOwner && !isAdmin) {
|
||||
return res.status(403).json({ error: '비공개 덱입니다.' });
|
||||
}
|
||||
|
||||
const cards = JSON.parse(row.cards_json);
|
||||
const { cards: resolved, totalCards } = await resolveDeckCards(row.series, cards);
|
||||
const author = getAuthor(db, row.user_id);
|
||||
|
||||
db.prepare('UPDATE decks SET view_count = view_count + 1 WHERE id = ?').run(row.id);
|
||||
row.view_count += 1;
|
||||
|
||||
res.json(toDeckResponse(row, author, resolved, totalCards));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const rows = db.prepare(`
|
||||
SELECT share_code, share_type, series, series_name, name, is_public, view_count,
|
||||
preview_image, created_at, updated_at
|
||||
FROM decks WHERE user_id = ? AND share_type = 'user'
|
||||
ORDER BY updated_at DESC
|
||||
`).all(req.user.id);
|
||||
|
||||
res.json({
|
||||
decks: rows.map((row) => ({
|
||||
share_code: row.share_code,
|
||||
share_type: row.share_type,
|
||||
series: row.series,
|
||||
series_name: row.series_name,
|
||||
name: row.name,
|
||||
is_public: row.is_public,
|
||||
view_count: row.view_count,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
preview_url: getPreviewUrl(row.share_code, row.preview_image),
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', optionalAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { series, seriesName, name, cards, isPublic, previewImage } = req.body || {};
|
||||
if (!series || !cards) {
|
||||
return res.status(400).json({ error: 'series와 cards는 필수입니다.' });
|
||||
}
|
||||
|
||||
const normalized = validateCards(cards);
|
||||
const cardsJson = JSON.stringify(normalized);
|
||||
const shareCode = generateShareCode();
|
||||
const userId = req.user?.id ?? null;
|
||||
const expiresAt = userId ? null : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const isPublicValue = userId ? (isPublic === true ? 1 : 0) : 1;
|
||||
let previewFile = null;
|
||||
if (previewImage) {
|
||||
previewFile = savePreviewImage(shareCode, previewImage);
|
||||
if (!previewFile) {
|
||||
console.warn(`[preview] 저장 실패: ${shareCode} (dataUrl 길이 ${previewImage.length})`);
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO decks (share_code, share_type, user_id, series, series_name, name, cards_json, is_public, expires_at, preview_image)
|
||||
VALUES (?, 'user', ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
shareCode,
|
||||
userId,
|
||||
series,
|
||||
seriesName || series,
|
||||
name || '',
|
||||
cardsJson,
|
||||
isPublicValue,
|
||||
expiresAt,
|
||||
previewFile,
|
||||
);
|
||||
|
||||
const row = db.prepare('SELECT * FROM decks WHERE share_code = ?').get(shareCode);
|
||||
const { cards: resolved, totalCards } = await resolveDeckCards(series, normalized);
|
||||
const author = getAuthor(db, userId);
|
||||
|
||||
res.status(201).json(toDeckResponse(row, author, resolved, totalCards));
|
||||
} catch (err) {
|
||||
if (err.message?.includes('카드') || err.message?.includes('덱')) {
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:shareCode', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const row = db.prepare('SELECT * FROM decks WHERE share_code = ?').get(req.params.shareCode);
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: '덱을 찾을 수 없습니다.' });
|
||||
}
|
||||
if (row.user_id !== req.user.id && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: '수정 권한이 없습니다.' });
|
||||
}
|
||||
if (row.share_type === 'preset' && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: '레거시 프리셋 덱은 관리자만 수정할 수 있습니다.' });
|
||||
}
|
||||
|
||||
const { name, cards, isPublic, seriesName, previewImage } = req.body || {};
|
||||
let cardsJson = row.cards_json;
|
||||
let series = row.series;
|
||||
let previewFile = row.preview_image;
|
||||
|
||||
if (cards) {
|
||||
const normalized = validateCards(cards);
|
||||
cardsJson = JSON.stringify(normalized);
|
||||
}
|
||||
if (req.body?.series) {
|
||||
series = req.body.series;
|
||||
}
|
||||
if (previewImage) {
|
||||
previewFile = savePreviewImage(row.share_code, previewImage);
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
UPDATE decks SET
|
||||
name = COALESCE(?, name),
|
||||
series_name = COALESCE(?, series_name),
|
||||
series = ?,
|
||||
cards_json = ?,
|
||||
is_public = COALESCE(?, is_public),
|
||||
preview_image = COALESCE(?, preview_image),
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
name ?? null,
|
||||
seriesName ?? null,
|
||||
series,
|
||||
cardsJson,
|
||||
isPublic === undefined ? null : (isPublic ? 1 : 0),
|
||||
previewFile,
|
||||
nowIso(),
|
||||
row.id,
|
||||
);
|
||||
|
||||
const updated = db.prepare('SELECT * FROM decks WHERE id = ?').get(row.id);
|
||||
const parsed = JSON.parse(updated.cards_json);
|
||||
const { cards: resolved, totalCards } = await resolveDeckCards(updated.series, parsed);
|
||||
|
||||
res.json(toDeckResponse(updated, getAuthor(db, updated.user_id), resolved, totalCards));
|
||||
} catch (err) {
|
||||
if (err.message?.includes('카드') || err.message?.includes('덱')) {
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:shareCode', requireAuth, (req, res) => {
|
||||
const row = db.prepare('SELECT * FROM decks WHERE share_code = ?').get(req.params.shareCode);
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: '덱을 찾을 수 없습니다.' });
|
||||
}
|
||||
if (row.user_id !== req.user.id && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: '삭제 권한이 없습니다.' });
|
||||
}
|
||||
|
||||
db.prepare('DELETE FROM decks WHERE id = ?').run(row.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
65
api/src/services/avatarImage.js
Normal file
65
api/src/services/avatarImage.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/**
|
||||
* 아바타 이미지 저장 디렉터리
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getAvatarsDir() {
|
||||
const dir = process.env.AVATARS_PATH || path.join(__dirname, '..', '..', 'data', 'avatars');
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number|string} userId
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getAvatarFilePath(userId) {
|
||||
const filePath = path.join(getAvatarsDir(), `${userId}.png`);
|
||||
return fs.existsSync(filePath) ? filePath : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number|string} userId
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function getAvatarUrl(userId) {
|
||||
return getAvatarFilePath(userId) ? `/api/v1/auth/avatar/${userId}` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* base64 data URL을 PNG로 저장
|
||||
* @param {number|string} userId
|
||||
* @param {string} dataUrl
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function saveAvatarImage(userId, dataUrl) {
|
||||
if (!dataUrl || !dataUrl.startsWith('data:image')) {
|
||||
return null;
|
||||
}
|
||||
const base64 = dataUrl.replace(/^data:image\/\w+;base64,/, '');
|
||||
if (!base64) {
|
||||
return null;
|
||||
}
|
||||
const fileName = `${userId}.png`;
|
||||
const filePath = path.join(getAvatarsDir(), fileName);
|
||||
fs.writeFileSync(filePath, Buffer.from(base64, 'base64'));
|
||||
return fs.existsSync(filePath) ? fileName : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 아바타 이미지 삭제
|
||||
* @param {number|string} userId
|
||||
*/
|
||||
export function deleteAvatarImage(userId) {
|
||||
const filePath = path.join(getAvatarsDir(), `${userId}.png`);
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
}
|
||||
65
api/src/services/cardResolver.js
Normal file
65
api/src/services/cardResolver.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import { pathToFileURL } from 'url';
|
||||
import path from 'path';
|
||||
|
||||
/** @type {Map<string, Map<string, object>>} */
|
||||
const cache = new Map();
|
||||
|
||||
/**
|
||||
* 시리즈 카드 맵 로드
|
||||
* @param {string} seriesKey
|
||||
* @returns {Promise<Map<string, object>>}
|
||||
*/
|
||||
export async function getCardMap(seriesKey) {
|
||||
if (cache.has(seriesKey)) {
|
||||
return cache.get(seriesKey);
|
||||
}
|
||||
|
||||
const datasPath = process.env.DATAS_PATH || path.join(process.cwd(), '..', 'datas');
|
||||
const filePath = path.join(datasPath, `${seriesKey}.js`);
|
||||
|
||||
let mod;
|
||||
try {
|
||||
mod = await import(pathToFileURL(filePath).href);
|
||||
} catch {
|
||||
throw new Error(`시리즈 데이터를 찾을 수 없습니다: ${seriesKey}`);
|
||||
}
|
||||
|
||||
const byNumber = new Map();
|
||||
for (const card of mod.cardList) {
|
||||
byNumber.set(card.Number, card);
|
||||
}
|
||||
|
||||
cache.set(seriesKey, byNumber);
|
||||
return byNumber;
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장된 덱 카드를 풀 카드 객체로 합성
|
||||
* @param {string} seriesKey
|
||||
* @param {Array<{number: string, count: number, key: boolean}>} cards
|
||||
* @returns {Promise<{cards: object[], totalCards: number}>}
|
||||
*/
|
||||
export async function resolveDeckCards(seriesKey, cards) {
|
||||
const map = await getCardMap(seriesKey);
|
||||
const resolved = [];
|
||||
let totalCards = 0;
|
||||
|
||||
for (const item of cards) {
|
||||
const base = map.get(item.number);
|
||||
if (!base) continue;
|
||||
|
||||
totalCards += item.count;
|
||||
resolved.push({
|
||||
number: base.Number,
|
||||
count: item.count,
|
||||
key: item.key,
|
||||
imgSrc: base.imgSrc,
|
||||
color: base.color,
|
||||
rarity: base.rarity,
|
||||
trigger: base.trigger ?? null,
|
||||
parallel: Boolean(base.parallel),
|
||||
});
|
||||
}
|
||||
|
||||
return { cards: resolved, totalCards };
|
||||
}
|
||||
55
api/src/services/previewImage.js
Normal file
55
api/src/services/previewImage.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/**
|
||||
* 미리보기 이미지 저장 디렉터리
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getPreviewsDir() {
|
||||
const dir = process.env.PREVIEWS_PATH || path.join(__dirname, '..', '..', 'data', 'previews');
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* base64 data URL을 PNG 파일로 저장
|
||||
* @param {string} shareCode
|
||||
* @param {string} dataUrl
|
||||
* @returns {string} 파일명
|
||||
*/
|
||||
export function savePreviewImage(shareCode, dataUrl) {
|
||||
if (!dataUrl || !dataUrl.startsWith('data:image')) {
|
||||
return null;
|
||||
}
|
||||
const base64 = dataUrl.replace(/^data:image\/\w+;base64,/, '');
|
||||
const fileName = `${shareCode}.png`;
|
||||
fs.writeFileSync(path.join(getPreviewsDir(), fileName), Buffer.from(base64, 'base64'));
|
||||
return fileName;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} shareCode
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function getPreviewFilePath(shareCode) {
|
||||
const filePath = path.join(getPreviewsDir(), `${shareCode}.png`);
|
||||
return fs.existsSync(filePath) ? filePath : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} shareCode
|
||||
* @param {string|null} [previewImageRecord] DB preview_image 컬럼
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function getPreviewUrl(shareCode, previewImageRecord = null) {
|
||||
const hasFile = Boolean(getPreviewFilePath(shareCode));
|
||||
if (hasFile || previewImageRecord) {
|
||||
return `/api/v1/decks/${shareCode}/preview`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
85
api/src/utils.js
Normal file
85
api/src/utils.js
Normal file
@@ -0,0 +1,85 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
const SHARE_ALPHABET = '0123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||
|
||||
/**
|
||||
* URL-safe 공유 코드 생성
|
||||
* @param {number} [length=8]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function generateShareCode(length = 8) {
|
||||
const bytes = crypto.randomBytes(length);
|
||||
let code = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
code += SHARE_ALPHABET[bytes[i] % SHARE_ALPHABET.length];
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
/**
|
||||
* 덱 본문 정규화 (해시용)
|
||||
* @param {Array<{number: string, count: number, key: boolean}>} cards
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeCards(cards) {
|
||||
const sorted = [...cards]
|
||||
.map((c) => ({
|
||||
number: String(c.number).trim(),
|
||||
count: Number(c.count),
|
||||
key: Boolean(c.key),
|
||||
}))
|
||||
.sort((a, b) => a.number.localeCompare(b.number));
|
||||
return JSON.stringify(sorted);
|
||||
}
|
||||
|
||||
/**
|
||||
* 프리셋 덱용 내용 해시 (8자)
|
||||
* @param {Array<{number: string, count: number, key: boolean}>} cards
|
||||
* @returns {string}
|
||||
*/
|
||||
export function contentHash(cards) {
|
||||
const hash = crypto.createHash('sha256').update(normalizeCards(cards)).digest('hex');
|
||||
return hash.slice(0, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* 덱 카드 배열 검증
|
||||
* @param {unknown} cards
|
||||
* @returns {Array<{number: string, count: number, key: boolean}>}
|
||||
*/
|
||||
export function validateCards(cards) {
|
||||
if (!Array.isArray(cards) || cards.length === 0) {
|
||||
throw new Error('카드 목록이 비어 있습니다.');
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
const normalized = cards.map((c) => {
|
||||
if (!c.number || c.count == null) {
|
||||
throw new Error('카드 형식이 올바르지 않습니다.');
|
||||
}
|
||||
const count = Number(c.count);
|
||||
if (!Number.isInteger(count) || count < 1 || count > 4) {
|
||||
throw new Error(`잘못된 수량: ${c.number}`);
|
||||
}
|
||||
total += count;
|
||||
return {
|
||||
number: String(c.number).trim(),
|
||||
count,
|
||||
key: Boolean(c.key),
|
||||
};
|
||||
});
|
||||
|
||||
if (total > 50) {
|
||||
throw new Error('덱은 최대 50장까지 가능합니다.');
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO 현재 시각
|
||||
* @returns {string}
|
||||
*/
|
||||
export function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
116
cardList.html
116
cardList.html
@@ -6,7 +6,9 @@
|
||||
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-4516420168710424" crossorigin="anonymous"></script>
|
||||
<title>Card List</title>
|
||||
<script src="/script/navigation.js"></script>
|
||||
<script src="/script/api.js"></script>
|
||||
<link rel="stylesheet" href="./style/output.css" />
|
||||
<link rel="stylesheet" href="./style/auth-header.css" />
|
||||
<script type="module" src="/i18n/i18n.js"></script>
|
||||
<link href="/style/navigation.css" rel="stylesheet" />
|
||||
<link href="/style/style.css" rel="stylesheet" />
|
||||
@@ -81,12 +83,13 @@
|
||||
</div>
|
||||
|
||||
<div class="sticky top-0 z-50 h-16 bg-white/80 backdrop-blur-md border-b px-4 py-3 flex justify-between items-center">
|
||||
<button onclick="history.back()" class="font-bold text-slate-600 hover:text-blue-600 cursor-pointer">← BACK</button>
|
||||
<button type="button" onclick="goToHome()" class="font-bold text-slate-600 hover:text-blue-600 cursor-pointer">← 홈</button>
|
||||
<div class="font-bold text-slate-800">
|
||||
<span id="i18n-searchResults">검색결과</span>
|
||||
:
|
||||
<span id="SearchResultsCounter" class="text-blue-600">0</span>
|
||||
</div>
|
||||
<div id="auth-header-slot"></div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-[1400px] mx-auto p-4 flex flex-col lg:flex-row gap-6">
|
||||
@@ -240,6 +243,8 @@
|
||||
let cardList = [];
|
||||
let selectedCards = [];
|
||||
let selectedColors = new Set(['All']);
|
||||
let currentShareCode = null;
|
||||
let currentDeckName = '';
|
||||
|
||||
const getEl = () => ({
|
||||
searchQuery: document.getElementById('searchQuery'),
|
||||
@@ -253,37 +258,63 @@
|
||||
specialCount: document.getElementById('specialCardCount'),
|
||||
});
|
||||
|
||||
const seriesKey = new URLSearchParams(window.location.search).get('series');
|
||||
const nameKey = new URLSearchParams(window.location.search).get('seriesName');
|
||||
let seriesKey = new URLSearchParams(window.location.search).get('series');
|
||||
let nameKey = new URLSearchParams(window.location.search).get('seriesName');
|
||||
|
||||
let t = {};
|
||||
|
||||
async function init() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const lang = urlParams.get('lang') || localStorage.getItem('lang') || 'ko';
|
||||
const shareCode = urlParams.get('d');
|
||||
|
||||
if (!seriesKey) return;
|
||||
const el = getEl();
|
||||
if (el.loadingScreen) el.loadingScreen.style.display = 'flex';
|
||||
|
||||
try {
|
||||
// 1. 다국어 데이터 로드
|
||||
const tResponse = await fetch(`./i18n/translations.${lang}.json`);
|
||||
t = await tResponse.json();
|
||||
applyI18n();
|
||||
|
||||
let deckCards = null;
|
||||
|
||||
if (shareCode && window.UaDeckApi) {
|
||||
const deck = await UaDeckApi.getDeck(shareCode);
|
||||
seriesKey = deck.series;
|
||||
nameKey = deck.seriesName || deck.series;
|
||||
currentDeckName = deck.name || '';
|
||||
deckCards = deck.cards;
|
||||
|
||||
const me = UaDeckApi.getCachedUser();
|
||||
if (me && deck.author && deck.author.id === me.id) {
|
||||
currentShareCode = deck.shareCode;
|
||||
}
|
||||
|
||||
if (!urlParams.get('series')) {
|
||||
const newUrl = new URL(window.location.href);
|
||||
newUrl.searchParams.set('series', seriesKey);
|
||||
newUrl.searchParams.set('seriesName', nameKey);
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
}
|
||||
}
|
||||
|
||||
if (!seriesKey) return;
|
||||
|
||||
const module = await import(`./datas/${seriesKey}.js`);
|
||||
cardList = module.cardList;
|
||||
|
||||
initSeriesFilter();
|
||||
|
||||
const cardsParam = new URLSearchParams(window.location.search).get('cards');
|
||||
const savedData = cardsParam ? decodeURIComponent(cardsParam) : localStorage.getItem('selectedCards');
|
||||
|
||||
if (savedData) {
|
||||
const parsed = JSON.parse(decodeURIComponent(savedData));
|
||||
cardList = syncCardData(cardList, parsed);
|
||||
selectedCards = parsed;
|
||||
if (deckCards) {
|
||||
cardList = syncCardData(cardList, deckCards);
|
||||
selectedCards = deckCards;
|
||||
} else {
|
||||
const savedData = localStorage.getItem('selectedCards');
|
||||
if (savedData) {
|
||||
const parsed = JSON.parse(decodeURIComponent(savedData));
|
||||
cardList = syncCardData(cardList, parsed);
|
||||
selectedCards = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
applyInitialFilter();
|
||||
@@ -466,6 +497,14 @@
|
||||
}
|
||||
|
||||
// 전역 핸들러
|
||||
window.goToHome = function () {
|
||||
const lang =
|
||||
new URLSearchParams(window.location.search).get('lang') ||
|
||||
localStorage.getItem('lang') ||
|
||||
'ko';
|
||||
window.location.href = lang && lang !== 'ko' ? `/?lang=${lang}` : '/';
|
||||
};
|
||||
|
||||
window.handleCount = function (imgSrc, delta, event) {
|
||||
event.stopPropagation();
|
||||
const card = cardList.find((c) => c.imgSrc === imgSrc);
|
||||
@@ -605,24 +644,23 @@
|
||||
}
|
||||
|
||||
function syncCardData(base, saved) {
|
||||
const map = new Map(base.map((c) => [c.imgSrc, c]));
|
||||
const byImg = new Map(base.map((c) => [c.imgSrc, c]));
|
||||
const byNum = new Map(base.map((c) => [c.Number, c]));
|
||||
saved.forEach((s) => {
|
||||
const item = map.get(s.imgSrc);
|
||||
const item = byImg.get(s.imgSrc) || byNum.get(s.number || s.Number);
|
||||
if (item) {
|
||||
item.count = s.count;
|
||||
item.key = s.key;
|
||||
item.key = Boolean(s.key);
|
||||
}
|
||||
});
|
||||
return Array.from(map.values());
|
||||
return base;
|
||||
}
|
||||
|
||||
function saveAndRefresh() {
|
||||
selectedCards = cardList.filter((c) => c.count > 0);
|
||||
const encoded = encodeURIComponent(JSON.stringify(selectedCards));
|
||||
localStorage.setItem('selectedCards', encoded);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('cards', encoded);
|
||||
window.history.replaceState({}, '', url);
|
||||
localStorage.setItem('selectedSeries', seriesKey);
|
||||
updateCounters();
|
||||
updateCardList();
|
||||
}
|
||||
@@ -713,8 +751,11 @@
|
||||
|
||||
// 4. 저장소 및 URL 동기화
|
||||
localStorage.removeItem('selectedCards');
|
||||
localStorage.removeItem('selectedSeries');
|
||||
currentShareCode = null;
|
||||
currentDeckName = '';
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('cards');
|
||||
url.searchParams.delete('d');
|
||||
window.history.replaceState({}, '', url);
|
||||
|
||||
// 5. 전체 갱신
|
||||
@@ -750,19 +791,21 @@
|
||||
document.querySelectorAll('input[name="triggerType"]').forEach((r) => {
|
||||
r.addEventListener('change', updateCardList);
|
||||
});
|
||||
document.getElementById('goToResultPage').addEventListener('click', () => {
|
||||
if (this.disabled) return; // 비활성화 상태면 무시
|
||||
document.getElementById('goToResultPage').addEventListener('click', function () {
|
||||
if (this.disabled) return;
|
||||
|
||||
// 현재 페이지에서 선택된(cardList에 있는) 카드만 필터링해서 전송
|
||||
const currentSeriesSelected = cardList.filter(c => c.count > 0);
|
||||
const currentSeriesSelected = cardList.filter((c) => c.count > 0);
|
||||
if (currentSeriesSelected.length === 0) {
|
||||
alert(translations.noCardsSelected || '선택된 카드가 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentSeriesSelected.length === 0) {
|
||||
alert(translations.noCardsSelected || "선택된 카드가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const queryParams = encodeURIComponent(JSON.stringify(selectedCards));
|
||||
window.location.href = `/result.html?series=${seriesKey}&seriesName=${nameKey}&cards=${queryParams}`;
|
||||
selectedCards = currentSeriesSelected;
|
||||
const encoded = encodeURIComponent(JSON.stringify(selectedCards));
|
||||
localStorage.setItem('selectedCards', encoded);
|
||||
localStorage.setItem('selectedSeries', seriesKey);
|
||||
const lang = new URLSearchParams(window.location.search).get('lang') || localStorage.getItem('lang') || 'ko';
|
||||
window.location.href = `/result.html?series=${seriesKey}&seriesName=${encodeURIComponent(nameKey || '')}&lang=${lang}`;
|
||||
});
|
||||
|
||||
const resetModal = document.getElementById('resetModal');
|
||||
@@ -822,16 +865,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 로컬 스토리지 저장 (현재 시리즈 데이터만 깨끗하게 유지)
|
||||
// 3. 로컬 스토리지 저장
|
||||
const encoded = encodeURIComponent(JSON.stringify(selectedCards));
|
||||
localStorage.setItem('selectedCards', encoded);
|
||||
localStorage.setItem('selectedSeries', seriesKey);
|
||||
|
||||
// 4. URL 업데이트 (새로고침 시 데이터 유지용)
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('cards', encoded);
|
||||
window.history.replaceState({}, '', url);
|
||||
|
||||
updateCounters(); // 하단 바 숫자만 업데이트
|
||||
updateCounters();
|
||||
}
|
||||
|
||||
let selectedSeriesGroups = new Set(['All']);
|
||||
@@ -889,5 +928,6 @@
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<script src="/script/auth-header.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
315
deckHistory.html
Normal file
315
deckHistory.html
Normal file
@@ -0,0 +1,315 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-4516420168710424" crossorigin="anonymous"></script>
|
||||
<link rel="stylesheet" href="./style/output.css" />
|
||||
<link rel="stylesheet" href="./style/app-shell.css" />
|
||||
<link rel="stylesheet" href="./style/auth-header.css" />
|
||||
<link rel="stylesheet" href="./style/deck-history.css" />
|
||||
<title>내 덱 관리 - UADECK</title>
|
||||
</head>
|
||||
<body class="app-page deck-history-page">
|
||||
<nav class="app-nav">
|
||||
<div class="app-nav-inner">
|
||||
<a href="/" class="app-brand">UADECK <span>v4.0.0</span></a>
|
||||
<div id="auth-header-slot"></div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="app-ad-top">
|
||||
<div class="app-ad-slot app-ad-slot--leaderboard" id="ad-leaderboard" aria-label="광고"></div>
|
||||
</div>
|
||||
|
||||
<div class="app-shell">
|
||||
<div class="app-body-layout">
|
||||
<main class="app-main deck-history-main">
|
||||
<header class="deck-history-header">
|
||||
<h1>내 덱 관리</h1>
|
||||
<p>저장한 덱 목록입니다. 공개 전환 시「공개 덱」목록에 표시됩니다.</p>
|
||||
</header>
|
||||
|
||||
<div id="deck-toolbar" class="deck-history-toolbar hidden">
|
||||
<input
|
||||
type="search"
|
||||
id="deck-search"
|
||||
class="deck-search-input"
|
||||
placeholder="덱 이름 또는 시리즈 검색..."
|
||||
autocomplete="off"
|
||||
/>
|
||||
<select id="deck-series-filter" class="deck-series-select" aria-label="시리즈 필터">
|
||||
<option value="">모든 시리즈</option>
|
||||
</select>
|
||||
<span id="deck-count" class="deck-count-label"></span>
|
||||
</div>
|
||||
|
||||
<div id="deck-list">
|
||||
<div class="deck-status-message">불러오는 중...</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside class="app-ad-rail" aria-label="광고">
|
||||
<div class="app-ad-slot app-ad-slot--skyscraper" id="ad-sidebar"></div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/script/api.js"></script>
|
||||
<script src="/script/auth-header.js"></script>
|
||||
<script>
|
||||
/** @type {Array<object>} */
|
||||
let allDecks = [];
|
||||
|
||||
/**
|
||||
* HTML 이스케이프
|
||||
* @param {string} str
|
||||
* @returns {string}
|
||||
*/
|
||||
function escapeHtml(str) {
|
||||
return String(str ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/**
|
||||
* 덱 표시 이름
|
||||
* @param {object} deck
|
||||
* @returns {string}
|
||||
*/
|
||||
function getDeckTitle(deck) {
|
||||
return deck.name || deck.series_name || deck.series || '이름 없음';
|
||||
}
|
||||
|
||||
/**
|
||||
* 시리즈 필터 옵션 갱신
|
||||
* @param {Array<object>} decks
|
||||
*/
|
||||
function populateSeriesFilter(decks) {
|
||||
const select = document.getElementById('deck-series-filter');
|
||||
const current = select.value;
|
||||
const seriesMap = new Map();
|
||||
|
||||
decks.forEach((d) => {
|
||||
if (!seriesMap.has(d.series)) {
|
||||
seriesMap.set(d.series, d.series_name || d.series);
|
||||
}
|
||||
});
|
||||
|
||||
const sorted = [...seriesMap.entries()].sort((a, b) =>
|
||||
String(a[1]).localeCompare(String(b[1]), 'ko'),
|
||||
);
|
||||
|
||||
select.innerHTML =
|
||||
'<option value="">모든 시리즈</option>' +
|
||||
sorted
|
||||
.map(
|
||||
([key, label]) =>
|
||||
`<option value="${escapeHtml(key)}">${escapeHtml(label)}</option>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
if (current && seriesMap.has(current)) {
|
||||
select.value = current;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색·필터 적용
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
function getFilteredDecks() {
|
||||
const query = document.getElementById('deck-search').value.trim().toLowerCase();
|
||||
const series = document.getElementById('deck-series-filter').value;
|
||||
|
||||
return allDecks.filter((d) => {
|
||||
if (series && d.series !== series) return false;
|
||||
if (!query) return true;
|
||||
|
||||
const haystack = [
|
||||
getDeckTitle(d),
|
||||
d.series_name,
|
||||
d.series,
|
||||
d.share_code,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 미리보기 HTML
|
||||
* @param {object} d
|
||||
* @param {string} title
|
||||
* @returns {string}
|
||||
*/
|
||||
function renderPreviewHtml(d, title) {
|
||||
if (!d.preview_url) {
|
||||
return '<div class="deck-card-preview-placeholder">미리보기 없음<br><span>Preview에서 다시 저장</span></div>';
|
||||
}
|
||||
const src = UaDeckApi.resolveMediaUrl(d.preview_url);
|
||||
const version = d.updated_at ? `?v=${encodeURIComponent(d.updated_at)}` : '';
|
||||
return `<img src="${escapeHtml(src + version)}" alt="${title} 미리보기" loading="lazy" class="deck-card-preview-img" />`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 덱 카드 HTML
|
||||
* @param {object} d
|
||||
* @returns {string}
|
||||
*/
|
||||
function renderDeckCard(d) {
|
||||
const title = escapeHtml(getDeckTitle(d));
|
||||
const seriesLabel = escapeHtml(d.series_name || d.series);
|
||||
const date = new Date(d.updated_at).toLocaleDateString('ko-KR');
|
||||
const preview = renderPreviewHtml(d, title);
|
||||
const isPublic = Boolean(d.is_public);
|
||||
const visibilityBadge = isPublic
|
||||
? '<span class="deck-card-badge-public">공개</span>'
|
||||
: '<span class="deck-card-badge-private">비공개</span>';
|
||||
const toggleLabel = isPublic ? '비공개 전환' : '공개 전환';
|
||||
const toggleClass = isPublic ? 'deck-card-btn-toggle is-public' : 'deck-card-btn-toggle';
|
||||
|
||||
return `
|
||||
<article class="deck-card" data-code="${escapeHtml(d.share_code)}">
|
||||
<div class="deck-card-preview">${preview}</div>
|
||||
<div class="deck-card-body">
|
||||
<div class="deck-card-visibility">
|
||||
<span class="deck-card-series">${seriesLabel}</span>
|
||||
${visibilityBadge}
|
||||
</div>
|
||||
<h2 class="deck-card-title">${title}</h2>
|
||||
<div class="deck-card-meta">조회 ${d.view_count} · ${date}</div>
|
||||
<div class="deck-card-actions">
|
||||
<button type="button" class="deck-card-btn deck-card-btn-load" data-load="${escapeHtml(d.share_code)}" data-series="${escapeHtml(d.series)}" data-name="${escapeHtml(d.series_name || '')}">불러오기</button>
|
||||
<button type="button" class="deck-card-btn ${toggleClass}" data-toggle="${escapeHtml(d.share_code)}" data-public="${isPublic ? '1' : '0'}">${toggleLabel}</button>
|
||||
</div>
|
||||
<div class="deck-card-actions">
|
||||
<button type="button" class="deck-card-btn deck-card-btn-copy" data-copy="${escapeHtml(d.share_code)}">링크 복사</button>
|
||||
<button type="button" class="deck-card-btn deck-card-btn-delete" data-delete="${escapeHtml(d.share_code)}" title="삭제" aria-label="삭제">삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 목록 렌더링
|
||||
*/
|
||||
function renderDeckList() {
|
||||
const list = document.getElementById('deck-list');
|
||||
const countEl = document.getElementById('deck-count');
|
||||
const filtered = getFilteredDecks();
|
||||
|
||||
countEl.textContent = `${filtered.length} / ${allDecks.length}개`;
|
||||
|
||||
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(renderDeckCard).join('')}</div>`;
|
||||
|
||||
list.querySelectorAll('.deck-card-preview-img').forEach((img) => {
|
||||
img.addEventListener('error', () => {
|
||||
const wrap = img.closest('.deck-card-preview');
|
||||
if (wrap) {
|
||||
wrap.innerHTML =
|
||||
'<div class="deck-card-preview-placeholder">미리보기 없음<br><span>Preview에서 다시 저장</span></div>';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 덱 목록 API 로드
|
||||
*/
|
||||
async function loadDecks() {
|
||||
const list = document.getElementById('deck-list');
|
||||
const toolbar = document.getElementById('deck-toolbar');
|
||||
|
||||
if (!UaDeckApi.getToken()) {
|
||||
location.href = '/login.html?redirect=' + encodeURIComponent(location.pathname);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { decks } = await UaDeckApi.getMyDecks();
|
||||
allDecks = decks;
|
||||
|
||||
if (!decks.length) {
|
||||
toolbar.classList.add('hidden');
|
||||
list.innerHTML =
|
||||
'<div class="deck-status-message">저장된 덱이 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
toolbar.classList.remove('hidden');
|
||||
populateSeriesFilter(decks);
|
||||
renderDeckList();
|
||||
} catch (err) {
|
||||
toolbar.classList.add('hidden');
|
||||
list.innerHTML = `<div class="deck-status-message error">${escapeHtml(err.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadDecks();
|
||||
|
||||
document.getElementById('deck-search').addEventListener('input', renderDeckList);
|
||||
document.getElementById('deck-series-filter').addEventListener('change', renderDeckList);
|
||||
|
||||
document.getElementById('deck-list').addEventListener('click', async (e) => {
|
||||
const loadBtn = e.target.closest('[data-load]');
|
||||
if (loadBtn) {
|
||||
const code = loadBtn.dataset.load;
|
||||
const series = loadBtn.dataset.series;
|
||||
const name = loadBtn.dataset.name;
|
||||
location.href = `/cardList.html?series=${series}&seriesName=${encodeURIComponent(name)}&d=${code}`;
|
||||
return;
|
||||
}
|
||||
|
||||
const copyBtn = e.target.closest('[data-copy]');
|
||||
if (copyBtn) {
|
||||
const url = `${location.origin}/d/${copyBtn.dataset.copy}`;
|
||||
await navigator.clipboard.writeText(url);
|
||||
alert('공유 링크가 복사되었습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const toggleBtn = e.target.closest('[data-toggle]');
|
||||
if (toggleBtn) {
|
||||
const code = toggleBtn.dataset.toggle;
|
||||
const nextPublic = toggleBtn.dataset.public !== '1';
|
||||
const label = nextPublic ? '이 덱을 공개 덱 목록에 표시합니다. 계속할까요?' : '비공개로 전환합니다. 공개 목록에서 숨겨집니다.';
|
||||
if (!confirm(label)) return;
|
||||
await UaDeckApi.updateDeck(code, { isPublic: nextPublic });
|
||||
const deck = allDecks.find((d) => d.share_code === code);
|
||||
if (deck) deck.is_public = nextPublic ? 1 : 0;
|
||||
renderDeckList();
|
||||
return;
|
||||
}
|
||||
|
||||
const deleteBtn = e.target.closest('[data-delete]');
|
||||
if (deleteBtn) {
|
||||
if (!confirm('이 덱을 삭제하시겠습니까?')) return;
|
||||
await UaDeckApi.deleteDeck(deleteBtn.dataset.delete);
|
||||
allDecks = allDecks.filter((d) => d.share_code !== deleteBtn.dataset.delete);
|
||||
if (!allDecks.length) {
|
||||
document.getElementById('deck-toolbar').classList.add('hidden');
|
||||
document.getElementById('deck-list').innerHTML =
|
||||
'<div class="deck-status-message">저장된 덱이 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
populateSeriesFilter(allDecks);
|
||||
renderDeckList();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
51
docker-compose.prod.yml
Normal file
51
docker-compose.prod.yml
Normal file
@@ -0,0 +1,51 @@
|
||||
# UADECK v4 프로덕션 스택 (NAS Docker)
|
||||
# 사용: docker compose -f docker-compose.prod.yml up -d --build
|
||||
#
|
||||
# 정적 파일(HTML, images, datas)은 호스트 볼륨 마운트 — 카드/이미지 추가 시 재빌드 불필요
|
||||
|
||||
services:
|
||||
uadeck-api:
|
||||
build: ./api
|
||||
container_name: uadeck-api
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "3000"
|
||||
environment:
|
||||
- PORT=3000
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- JWT_EXPIRES=${JWT_EXPIRES:-7d}
|
||||
- ADMIN_EMAIL=${ADMIN_EMAIL}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
||||
- CORS_ORIGINS=${CORS_ORIGINS:-*}
|
||||
- PREVIEWS_PATH=/data/previews
|
||||
- AVATARS_PATH=/data/avatars
|
||||
- DB_PATH=/data/uadeck.db
|
||||
- DATAS_PATH=/datas
|
||||
volumes:
|
||||
- uadeck-data:/data
|
||||
- ./datas:/datas:ro
|
||||
- ./api/data/previews:/data/previews
|
||||
- ./api/data/avatars:/data/avatars
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:3000/api/v1/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
|
||||
uadeck-web:
|
||||
image: nginx:alpine
|
||||
container_name: uadeck-web
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${WEB_PORT:-8080}:80"
|
||||
volumes:
|
||||
- ./:/usr/share/nginx/html:ro
|
||||
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
depends_on:
|
||||
uadeck-api:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
uadeck-data:
|
||||
33
docker-compose.yml
Normal file
33
docker-compose.yml
Normal file
@@ -0,0 +1,33 @@
|
||||
services:
|
||||
uadeck-api:
|
||||
build: ./api
|
||||
container_name: uadeck-api
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${API_PORT:-3000}:3000"
|
||||
environment:
|
||||
- PORT=3000
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- JWT_EXPIRES=${JWT_EXPIRES:-7d}
|
||||
- ADMIN_EMAIL=${ADMIN_EMAIL}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
||||
- CORS_ORIGINS=${CORS_ORIGINS:-*}
|
||||
- PREVIEWS_PATH=/data/previews
|
||||
- AVATARS_PATH=/data/avatars
|
||||
- DB_PATH=/data/uadeck.db
|
||||
- DATAS_PATH=/datas
|
||||
volumes:
|
||||
- uadeck-data:/data
|
||||
- ./datas:/datas:ro
|
||||
- ./api/data/previews:/data/previews
|
||||
- ./api/data/avatars:/data/avatars
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:3000/api/v1/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
|
||||
volumes:
|
||||
uadeck-data:
|
||||
174
docs/api-integration.md
Normal file
174
docs/api-integration.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# 옆 프로젝트 API 연동 가이드
|
||||
|
||||
UADECK v4부터 덱 데이터는 **공유 코드 + REST API**로 제공합니다.
|
||||
URL 쿼리(`?cards=`) 방식은 사용하지 않습니다.
|
||||
|
||||
## 공식 엔드포인트
|
||||
|
||||
```http
|
||||
GET https://uniare.sori.studio/api/v1/decks/:shareCode
|
||||
```
|
||||
|
||||
### 응답 예시
|
||||
|
||||
```json
|
||||
{
|
||||
"v": 1,
|
||||
"shareCode": "x7Kp9m2Q",
|
||||
"shareType": "user",
|
||||
"series": "ua36",
|
||||
"seriesName": "마크로스",
|
||||
"name": "블루 aggro",
|
||||
"totalCards": 50,
|
||||
"cards": [
|
||||
{
|
||||
"number": "UA36BT/MCR-1-052",
|
||||
"count": 4,
|
||||
"key": true,
|
||||
"imgSrc": "/ua36/UA36BT_MCR-1-052.png",
|
||||
"color": "Blue",
|
||||
"rarity": "SR",
|
||||
"trigger": null,
|
||||
"parallel": false
|
||||
}
|
||||
],
|
||||
"author": {
|
||||
"id": 1,
|
||||
"displayName": "닉네임",
|
||||
"avatarUrl": null
|
||||
},
|
||||
"createdAt": "2026-06-19T12:00:00Z",
|
||||
"updatedAt": "2026-06-19T12:00:00Z",
|
||||
"viewCount": 42,
|
||||
"isPublic": true
|
||||
}
|
||||
```
|
||||
|
||||
## shareCode 얻는 방법
|
||||
|
||||
| 방법 | 예시 |
|
||||
|---|---|
|
||||
| UADECK 공유 버튼 | `https://uniare.sori.studio/d/x7Kp9m2Q` |
|
||||
| URL 파라미터 | `cardList.html?d=x7Kp9m2Q` |
|
||||
| 코드만 | `x7Kp9m2Q` |
|
||||
|
||||
## 클라이언트 라이브러리
|
||||
|
||||
프로젝트에 포함된 파일:
|
||||
|
||||
```
|
||||
integrations/uadeck-deck-client.js
|
||||
```
|
||||
|
||||
### 브라우저
|
||||
|
||||
```html
|
||||
<script src="https://uniare.sori.studio/integrations/uadeck-deck-client.js"></script>
|
||||
<script>
|
||||
const client = new UaDeckDeckClient({
|
||||
baseUrl: 'https://uniare.sori.studio/api/v1',
|
||||
imageBase: 'https://uniare.sori.studio',
|
||||
});
|
||||
|
||||
// 공유 링크에서 덱 로드
|
||||
const deck = await client.fetchFromUrl('https://uniare.sori.studio/d/x7Kp9m2Q');
|
||||
|
||||
deck.cards.forEach((card) => {
|
||||
console.log(card.number, card.count, card.imageUrl);
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Node.js / ESM
|
||||
|
||||
```javascript
|
||||
import { UaDeckDeckClient } from './integrations/uadeck-deck-client.js';
|
||||
|
||||
const client = new UaDeckDeckClient({
|
||||
baseUrl: 'https://uniare.sori.studio/api/v1',
|
||||
});
|
||||
|
||||
const deck = await client.fetchDeck('x7Kp9m2Q');
|
||||
```
|
||||
|
||||
### fetch 직접 사용
|
||||
|
||||
```javascript
|
||||
const res = await fetch('https://uniare.sori.studio/api/v1/decks/x7Kp9m2Q');
|
||||
const deck = await res.json();
|
||||
|
||||
if (!res.ok) throw new Error(deck.error);
|
||||
```
|
||||
|
||||
## 이미지 URL 규칙
|
||||
|
||||
API의 `imgSrc`는 상대 경로입니다. 전체 URL:
|
||||
|
||||
```
|
||||
https://uniare.sori.studio/images{imgSrc}
|
||||
```
|
||||
|
||||
예: `imgSrc: "/ua36/UA36BT_MCR-1-052.png"`
|
||||
→ `https://uniare.sori.studio/images/ua36/UA36BT_MCR-1-052.png`
|
||||
|
||||
`UaDeckDeckClient`는 `card.imageUrl`에 자동 변환합니다.
|
||||
|
||||
## CORS
|
||||
|
||||
다른 도메인에서 브라우저 `fetch`를 쓰려면 UADECK 서버 `.env`의 `CORS_ORIGINS`에 해당 origin을 추가하세요.
|
||||
|
||||
```
|
||||
CORS_ORIGINS=https://your-app.sori.studio,https://uniare.sori.studio
|
||||
```
|
||||
|
||||
서버 간 호출(Node, Python 등)은 CORS가 필요 없습니다.
|
||||
|
||||
## 마이그레이션 (기존 ?cards= 코드)
|
||||
|
||||
**Before (폐기):**
|
||||
|
||||
```javascript
|
||||
const params = new URLSearchParams(location.search);
|
||||
const cards = JSON.parse(decodeURIComponent(params.get('cards')));
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```javascript
|
||||
const code = UaDeckDeckClient.parseShareCode(location.href)
|
||||
|| new URLSearchParams(location.search).get('d');
|
||||
|
||||
const client = new UaDeckDeckClient({ baseUrl: '/api/v1' });
|
||||
const deck = await client.fetchDeck(code);
|
||||
const cards = deck.cards;
|
||||
```
|
||||
|
||||
## 프리셋(메타 덱) 목록
|
||||
|
||||
```http
|
||||
GET /api/v1/decks/presets
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"presets": [
|
||||
{
|
||||
"share_code": "a7f3b2c1",
|
||||
"series": "ua36",
|
||||
"series_name": "마크로스",
|
||||
"name": "공식 블루 템플릿",
|
||||
"view_count": 120
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
프리셋도 동일하게 `GET /decks/:shareCode`로 상세 조회합니다.
|
||||
|
||||
## 에러 코드
|
||||
|
||||
| HTTP | 의미 |
|
||||
|---|---|
|
||||
| 404 | shareCode 없음 |
|
||||
| 403 | 비공개 덱 |
|
||||
| 410 | 만료된 임시 공유 (비회원 30일) |
|
||||
@@ -5,12 +5,12 @@
|
||||
- 특히 이 저장소는 정적 HTML/JS 프로젝트이면서 카드 데이터와 이미지 자산 비중이 크기 때문에, "코드 수정"보다 "데이터-번역-이미지 일치"가 더 중요하다.
|
||||
|
||||
## 현재 기준 릴리즈
|
||||
- 현재 작업 기준 버전: `v3.1.0`
|
||||
- 현재 작업 기준 버전: `v4.0.0`
|
||||
- 이번 릴리즈 핵심 반영:
|
||||
- `ua51` 시리즈 추가
|
||||
- `ua49` 누락 병렬 이미지 보강
|
||||
- 다국어 시리즈명 추가
|
||||
- 버전 표기 및 문서 체계 정리
|
||||
- 회원·인증·덱 API (`api/`, Docker)
|
||||
- 공유 코드 기반 덱 공유 (`/d/:code`, `GET /api/v1/decks/:shareCode`)
|
||||
- 관리자 화면 (`admin.html`)
|
||||
- `?cards=` URL 방식 폐기
|
||||
|
||||
## 다음 작업자에게 반드시 전달할 것
|
||||
- 이 프로젝트는 빌드 시스템 중심 앱이 아니라 `index.html`, `cardList.html`, `result.html` 3개 페이지가 직접 동작하는 정적 사이트다.
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
|
||||
## CSS 규칙
|
||||
- 공통 UI는 가능한 한 Tailwind 유틸리티 클래스를 우선 사용한다.
|
||||
- `style/output.css`에 포함되지 않은 유틸리티 클래스는 적용되지 않으므로, 페이지 특화 스타일은 인라인 `<style>` 또는 전용 CSS 파일(`style/auth-pages.css` 등)에 명시한다.
|
||||
- 페이지 특화 보정 스타일만 인라인 `<style>` 또는 전용 CSS 파일에 둔다.
|
||||
- 캡처 안정성처럼 이유가 분명한 예외 스타일은 주석으로 목적을 남긴다.
|
||||
|
||||
|
||||
192
docs/deploy.md
Normal file
192
docs/deploy.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# NAS 배포 가이드 (UADECK v4)
|
||||
|
||||
## 1. 사전 준비
|
||||
|
||||
- Ugreen NAS에 Docker(또는 Container Manager) 설치
|
||||
- 프로젝트를 NAS에 클론 또는 동기화
|
||||
- `Git LFS`로 이미지 자산 pull 완료 확인
|
||||
|
||||
## 2. 환경변수 설정
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
| 변수 | 설명 |
|
||||
|---|---|
|
||||
| `JWT_SECRET` | 32자 이상 랜덤 문자열 (필수) |
|
||||
| `ADMIN_EMAIL` | 최초 관리자 이메일 |
|
||||
| `ADMIN_PASSWORD` | 최초 관리자 비밀번호 (8자+) |
|
||||
| `WEB_PORT` | 웹 노출 포트 (기본 `8080`) |
|
||||
| `CORS_ORIGINS` | 옆 프로젝트 도메인 포함, 쉼표 구분 |
|
||||
|
||||
예시:
|
||||
|
||||
```env
|
||||
JWT_SECRET=your-random-secret-at-least-32-chars
|
||||
ADMIN_EMAIL=admin@uniare.sori.studio
|
||||
ADMIN_PASSWORD=your-secure-password
|
||||
WEB_PORT=8080
|
||||
CORS_ORIGINS=https://uniare.sori.studio,https://your-other-app.sori.studio,http://localhost:8080,http://localhost:5500,http://127.0.0.1:5500
|
||||
```
|
||||
|
||||
## 3. 로컬 테스트
|
||||
|
||||
> **한 줄 요약:** 프로젝트 루트에서 `npm start` → 브라우저 **http://localhost:8080**
|
||||
|
||||
회원가입·로그인·덱 저장은 **API 서버**가 떠 있어야 합니다.
|
||||
HTML만 Live Server 등으로 열면 저장·로그인이 **405** 로 실패합니다.
|
||||
|
||||
### 빠른 시작 (권장)
|
||||
|
||||
```bash
|
||||
npm start # 백그라운드 실행 (터미널 점유 안 함)
|
||||
# 브라우저: http://localhost:8080
|
||||
|
||||
npm run logs # 로그 보기 (종료: Ctrl+C)
|
||||
npm stop # 서버 종료
|
||||
```
|
||||
|
||||
| 명령 | 설명 |
|
||||
|---|---|
|
||||
| `npm start` | Docker로 웹+API 백그라운드 기동 (**일상 개발용**) |
|
||||
| `npm stop` | 서버 종료 |
|
||||
| `npm run logs` | API·Nginx 로그 스트리밍 |
|
||||
| `npm run dev` | 포그라운드 실행 (로그 붙어 있음, `Ctrl+C`로 **서버도 종료**) |
|
||||
| `npm run api:dev` | API만 Node로 실행 (Live Server 병행 시) |
|
||||
|
||||
### 저장이 안 될 때
|
||||
|
||||
1. `curl http://localhost:8080/api/v1/health` → `{"ok":true,...}` 가 나와야 함
|
||||
2. 안 나오면 `npm start` 다시 실행
|
||||
3. `npm run dev` 후 `Healthy`에서 멈춘 것처럼 보여도 **정상**이지만, `Ctrl+C`하면 서버가 꺼짐 → **`npm start` 사용 권장**
|
||||
4. Live Server(`:5500`)로 HTML만 연 경우 → 아래 방법 B 또는 **반드시 `:8080` 사용**
|
||||
|
||||
### 방법 A — Docker (`npm start`와 동일)
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
브라우저: **http://localhost:8080** (Live Server 포트가 아님)
|
||||
|
||||
### 방법 B — API만 로컬 실행 (Live Server / Cursor 미리보기 사용 시)
|
||||
|
||||
터미널 1 — API:
|
||||
|
||||
```bash
|
||||
cd api && npm install # 최초 1회
|
||||
cd .. && npm run api:dev
|
||||
```
|
||||
|
||||
터미널 2 — HTML은 Live Server 등으로 열기 (예: `:5500`)
|
||||
|
||||
`.env`의 `CORS_ORIGINS`에 Live Server 포트를 추가하세요:
|
||||
|
||||
```env
|
||||
CORS_ORIGINS=...,http://localhost:5500,http://127.0.0.1:5500
|
||||
```
|
||||
|
||||
확인:
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/api/v1/health
|
||||
# → {"ok":true,"version":"4.0.0"}
|
||||
```
|
||||
|
||||
### 405 오류가 나올 때
|
||||
|
||||
| 원인 | 해결 |
|
||||
|---|---|
|
||||
| API 미실행 | `npm start` (또는 `npm run dev`) |
|
||||
| Live Server로 HTML만 연 경우 | POST `/api/v1/...`가 정적 서버로 가서 405 발생 → 방법 A 또는 B 사용 |
|
||||
| Docker 미기동 | `docker ps`로 `uadeck-api`, `uadeck-web` 확인 |
|
||||
|
||||
## 4. Docker 기동 (NAS / 프로덕션)
|
||||
|
||||
```bash
|
||||
# 프로덕션 스택 (Nginx + API)
|
||||
docker compose -f docker-compose.prod.yml up -d --build
|
||||
|
||||
# API만 단독 (기존 웹서버가 정적 파일 서빙 중일 때)
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
확인:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/api/v1/health
|
||||
# → {"ok":true,"version":"4.0.0"}
|
||||
```
|
||||
|
||||
## 5. 기존 리버스 프록시 연동
|
||||
|
||||
이미 NAS에서 Nginx Proxy Manager, Traefik, Caddy 등을 쓰는 경우:
|
||||
|
||||
### 옵션 A — `uadeck-web` 포트만 프록시 (권장)
|
||||
|
||||
```
|
||||
https://uniare.sori.studio → http://NAS_IP:8080
|
||||
```
|
||||
|
||||
`uadeck-web` 컨테이너가 `/api/`와 정적 파일을 모두 처리합니다.
|
||||
|
||||
### 옵션 B — 기존 정적 호스팅 + API만 Docker
|
||||
|
||||
1. `docker compose up -d` (API만, 포트 3000)
|
||||
2. 기존 웹서버에 `nginx/uadeck.conf` 규칙 추가:
|
||||
|
||||
```nginx
|
||||
location ~ ^/d/([A-Za-z0-9]+)$ {
|
||||
return 302 /cardList.html?d=$1;
|
||||
}
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
# proxy_set_header ... (uadeck.conf 참고)
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 최초 관리자 로그인
|
||||
|
||||
1. `https://uniare.sori.studio/login.html` 접속
|
||||
2. `.env`의 `ADMIN_EMAIL` 계정으로 로그인
|
||||
- API 최초 기동 시 해당 이메일이 없으면 `ADMIN_PASSWORD`로 계정이 자동 생성된다.
|
||||
- **이미 `ADMIN_EMAIL`로 회원가입한 경우** API 재시작 시 해당 계정이 `admin`으로 승격된다. (가입 시 설정한 비밀번호 그대로 사용)
|
||||
3. 프로필 메뉴 → **관리자** → `/admin.html`
|
||||
|
||||
> 관리자 메뉴가 안 보이면 **로그아웃 후 다시 로그인**하세요. 브라우저에 이전 `role`이 캐시되어 있을 수 있습니다.
|
||||
|
||||
## 7. 운영 명령
|
||||
|
||||
```bash
|
||||
# 로그
|
||||
docker compose -f docker-compose.prod.yml logs -f uadeck-api
|
||||
|
||||
# 재시작
|
||||
docker compose -f docker-compose.prod.yml restart
|
||||
|
||||
# DB 백업 (볼륨 내 SQLite)
|
||||
docker cp uadeck-api:/data/uadeck.db ./backup/uadeck-$(date +%Y%m%d).db
|
||||
```
|
||||
|
||||
## 8. 카드/이미지 업데이트
|
||||
|
||||
`datas/`, `images/`, HTML 파일은 **볼륨 마운트**이므로:
|
||||
|
||||
1. Git pull (또는 파일 동기화)
|
||||
2. **재빌드 불필요** — 브라우저 새로고침만으로 반영
|
||||
|
||||
API 컨테이너는 `datas/`를 read-only 마운트하므로 새 시리즈 파일 추가 후 API 재시작만 하면 됩니다:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml restart uadeck-api
|
||||
```
|
||||
|
||||
## 9. 트러블슈팅
|
||||
|
||||
| 증상 | 확인 |
|
||||
|---|---|
|
||||
| 로그인 안 됨 | `JWT_SECRET` 설정, API health 확인 |
|
||||
| 공유 링크 404 | Nginx `/d/` 리다이렉트 규칙 |
|
||||
| CORS 오류 | `CORS_ORIGINS`에 옆 프로젝트 도메인 추가 |
|
||||
| 덱 카드 메타 누락 | `datas/<series>.js` 존재 여부, API `DATAS_PATH` |
|
||||
@@ -1,5 +1,25 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-06-19 / v4.0.0 구현 완료
|
||||
- 배경: 설계 확정 후 회원·API·공유 시스템 1차 구현.
|
||||
- 반영: `api/` 백엔드, Docker Compose, 인증/덱/관리자 API, 프론트 신규 페이지 및 헤더 프로필 UI.
|
||||
- 배포: NAS Docker + Nginx `/api/`, `/d/` 프록시 필요.
|
||||
|
||||
## 2026-06-19 / v4.0.0 덱 공유·API 설계 확정
|
||||
- 배경: v3는 덱을 `?cards=<JSON>` URL에 실어 공유했으나 URL이 수천 자로 비대해지고, 옆 프로젝트도 개발 중이라 계약을 새로 잡을 수 있는 시점이다.
|
||||
- 결정:
|
||||
- URL 쿼리 기반 덱 공유는 **폐기**하고, 공유 코드 + REST API 방식으로 전환한다.
|
||||
- **개인 덱**(`share_type=user`): 저장할 때마다 nanoid 8자 고유 코드 발급. 같은 카드 구성이라도 저장자마다 URL이 다르다.
|
||||
- **메타 덱·프리셋**(`share_type=preset`): 카드 구성 해시 기반 고정 코드. 동일 구성이면 항상 같은 URL.
|
||||
- DB에는 `{number, count, key}`만 저장하고, API 응답 시 `datas/`에서 카드 메타를 lookup하여 합성한다.
|
||||
- 작성자, 생성일, 수정일, 조회수, 공개 여부 등 메타데이터를 덱 레코드와 함께 관리한다.
|
||||
- 외부 연동(옆 프로젝트) 공식 계약: `GET /api/v1/decks/:shareCode`
|
||||
- 프론트는 Vue 전환 없이 정적 HTML + API 호출 + Docker 백엔드로 확장한다.
|
||||
- 이유:
|
||||
- URL 길이·중복 데이터 문제 해소.
|
||||
- 회원 덱 소유권·수정·비공개 설정이 자연스럽다.
|
||||
- DB 용량은 덱 본문 기준 건당 수 KB 수준으로 경미하다.
|
||||
|
||||
## 2026-04-19 / v3.1.0 릴리즈 기준 확정
|
||||
- 배경: 저장소의 마지막 명시 릴리즈 기록은 `v3.0.1`인데, 메인 화면은 이미 `v3.1`을 노출하고 있어 실제 릴리즈 이력과 화면 표기가 어긋나 있었다.
|
||||
- 결정:
|
||||
|
||||
79
docs/map.md
79
docs/map.md
@@ -3,48 +3,51 @@
|
||||
## `/`
|
||||
- 진입 파일: `index.html`
|
||||
- 화면 역할: 시리즈 선택 홈 화면
|
||||
- 사용자 행동:
|
||||
- 언어 선택
|
||||
- 시리즈 카드 클릭
|
||||
- 연결 파일:
|
||||
- `i18n/translations.<lang>.json`: 제목, 설명, 시리즈명
|
||||
- `script/navigation.js`: 공통 네비게이션 오버레이
|
||||
- `style/output.css`: 공통 스타일
|
||||
- 연결: `script/auth-header.js`, `script/api.js`
|
||||
|
||||
## `/cardList.html?series=<seriesKey>`
|
||||
## `/cardList.html?series=<key>&d=<shareCode>`
|
||||
- 진입 파일: `cardList.html`
|
||||
- 화면 역할: 카드 검색, 필터링, 수량 선택, 키 카드 지정
|
||||
- 사용자 행동:
|
||||
- 검색어 입력
|
||||
- 색상/타입/트리거 필터
|
||||
- 카드 수량 조절
|
||||
- 키 카드 지정
|
||||
- 덱 결과 페이지 이동
|
||||
- 연결 파일:
|
||||
- `datas/<seriesKey>.js`: 현재 시리즈 카드 원본 데이터
|
||||
- `images/<seriesKey>/`: 카드 이미지 원본
|
||||
- `i18n/translations.<lang>.json`: 버튼/문구 번역
|
||||
- `script/navigation.js`: 공통 네비게이션
|
||||
- 화면 역할: 카드 검색·필터·덱 편집·저장·공유
|
||||
- `?d=` 있으면 API에서 덱 복원
|
||||
- 연결: `datas/<seriesKey>.js`, `script/api.js`
|
||||
|
||||
## `/result.html`
|
||||
- 진입 파일: `result.html`
|
||||
- 화면 역할: 선택한 덱 미리보기 및 이미지 저장
|
||||
- 사용자 행동:
|
||||
- 덱 이름 입력
|
||||
- 출력 스타일 선택
|
||||
- 고화질 저장 토글
|
||||
- PNG 다운로드
|
||||
- 연결 파일:
|
||||
- `localStorage.selectedCards` 또는 `cards` 쿼리스트링: 덱 데이터 소스
|
||||
- `images/<seriesKey>/`: 결과 이미지 렌더링 대상
|
||||
- `i18n/translations.<lang>.json`: 결과 화면 번역
|
||||
- 화면 역할: 덱 미리보기·PNG 저장·내 덱 저장
|
||||
- 데이터 소스: `localStorage.selectedCards`
|
||||
- 하단 고정 바: `홈으로` | `이미지로 다운로드` | `내 덱으로 저장` (공개 여부 확인 모달)
|
||||
- `script/deck-prefs.js`: 덱 공개 기본값·프롬프트 설정
|
||||
|
||||
## 공통 보조 파일
|
||||
- `script/navigation.js`: 오버레이 메뉴 생성 및 이동 처리
|
||||
- `i18n/i18n.js`: `data-i18n` 기반 공통 번역 적용
|
||||
- `style/style.css`, `style/navigation.css`, `style/output.css`: 공통/페이지 스타일
|
||||
## `/d/<shareCode>` (Nginx 리다이렉트)
|
||||
- `nginx/uadeck.conf` 규칙으로 `cardList.html?d=` 로 이동
|
||||
- 옆 프로젝트 연동: `GET /api/v1/decks/<shareCode>`
|
||||
|
||||
## 초보자용 이해 포인트
|
||||
- 이 프로젝트는 프레임워크 앱이 아니라 HTML 파일 3개가 직접 화면 역할을 나눠 갖는 구조다.
|
||||
- 카드 정보는 `datas/`에, 실제 카드 이미지는 `images/`에 따로 있다.
|
||||
- 화면에서 보이는 한 장의 카드는 "데이터 파일의 객체 1개 + 이미지 폴더의 파일 1개" 조합으로 만들어진다.
|
||||
## `/login.html`, `/signup.html`
|
||||
- 인증 화면
|
||||
|
||||
## `/profile.html` (내 프로필)
|
||||
- 로그인 사용자: 이메일·사용자명·프로필 사진·비밀번호 변경
|
||||
- 연결: `script/api.js` (`updateProfile`, `changePassword`), `script/image-utils.js`, `style/profile.css`
|
||||
|
||||
## `/deckHistory.html`
|
||||
- 로그인 사용자의 저장 덱 목록·불러오기·삭제
|
||||
- 카드 그리드 + 미리보기 썸네일 (`preview_url`)
|
||||
- 시리즈 필터·검색 (`style/deck-history.css`)
|
||||
|
||||
## `/preset.html` (공개 덱)
|
||||
- 사용자가 공개 전환한 덱 목록 (카드 그리드)
|
||||
- API: `GET /api/v1/decks/presets` → `is_public=1` user 덱
|
||||
|
||||
## `/admin.html`
|
||||
- 관리자: 회원 관리, 덱 관리 (`style/admin.css`)
|
||||
|
||||
## API (`api/`)
|
||||
- Docker: `docker-compose.yml`
|
||||
- 엔드포인트 베이스: `/api/v1`
|
||||
|
||||
## 공통
|
||||
- `style/app-shell.css`: 헤더·본문 최대 너비 1280px (`max-w-7xl`), 데스크톱 우측 광고 레일
|
||||
- `script/api.js`: API 클라이언트 (`updateProfile`, `changePassword` 포함)
|
||||
- `script/auth-header.js`: 헤더 프로필 드롭다운 (내 프로필, 내 덱 관리, 공개 덱), 로그인 시 아바타 옆 사용자명 표시
|
||||
- `script/image-utils.js`: 클라이언트 이미지 PNG 리사이즈 (`UaImageUtils.resizeImageToPng`)
|
||||
- `style/auth-header.css`
|
||||
|
||||
167
docs/spec.md
167
docs/spec.md
@@ -1,9 +1,10 @@
|
||||
# 기술 명세
|
||||
|
||||
## 1. 프로젝트 개요
|
||||
- 프로젝트명: `UADECK V3.1.0 - Union Arena Deck Builder`
|
||||
- 형태: 정적 HTML/CSS/JavaScript 기반 덱 빌더
|
||||
- 패키지 의존성: `tailwindcss`, `@tailwindcss/cli`
|
||||
- 프로젝트명: `UADECK V4.0.0 - Union Arena Deck Builder`
|
||||
- 형태: 정적 HTML/CSS/JavaScript 프론트 + Docker API 백엔드
|
||||
- 패키지 의존성 (프론트): `tailwindcss`, `@tailwindcss/cli`
|
||||
- 패키지 의존성 (API): `express`, `better-sqlite3`, `bcryptjs`, `jsonwebtoken`, `cors`
|
||||
- 빌드 산출 스타일: `style/output.css`
|
||||
|
||||
## 2. 화면 구조
|
||||
@@ -85,8 +86,10 @@
|
||||
|
||||
## 6. 상태 저장
|
||||
- `localStorage.lang`: 현재 언어
|
||||
- `localStorage.selectedCards`: 선택 카드 목록(JSON 문자열을 `encodeURIComponent` 처리)
|
||||
- `localStorage.selectedSeries`: 일부 흐름에서 사용하는 선택 시리즈 값
|
||||
- `localStorage.selectedCards`: 편집 중인 덱 (임시, `encodeURIComponent` JSON)
|
||||
- `localStorage.selectedSeries`: 현재 시리즈 키
|
||||
- `localStorage.uadeck_token` / `uadeck_user`: 로그인 세션
|
||||
- 덱 공유·영구 저장: API + DB (`GET /api/v1/decks/:shareCode`)
|
||||
|
||||
## 7. 스타일 및 자산
|
||||
- Tailwind 유틸리티 클래스와 페이지별 인라인 스타일을 혼용한다.
|
||||
@@ -99,3 +102,157 @@
|
||||
- `cardList.html`과 `result.html`의 스크립트가 페이지 내부에 크게 포함되어 있어 모듈 분리 전까지는 변경 영향 범위가 넓다.
|
||||
- `script/navigation.js`의 일부 이동 대상 페이지는 현재 저장소에 존재하지 않는다.
|
||||
- 이미지가 Git 일반 객체로 계속 누적되면 저장소 히스토리 크기가 지속 증가한다.
|
||||
|
||||
---
|
||||
|
||||
## 9. v4.0.0 계획 — 회원·덱 API·공유
|
||||
|
||||
> 상태: **v4.0.0 1차 구현 완료** (2026-06-19)
|
||||
|
||||
### 9.1 아키텍처 개요
|
||||
- 프론트: 기존 정적 HTML/JS 유지. 헤더에 프로필·인증 UI 추가.
|
||||
- 백엔드: Docker 컨테이너 API 서버 (NAS 배포).
|
||||
- 카드 DB: `datas/*.js` + `images/` 정적 유지 (변경 없음).
|
||||
- 사용자·덱 데이터: API + SQLite(또는 PostgreSQL) DB.
|
||||
- 외부 연동(옆 프로젝트): `GET /api/v1/decks/:shareCode` 단일 계약.
|
||||
|
||||
### 9.2 덱 공유 방식 (확정)
|
||||
|
||||
기존 `?cards=<JSON>` URL 방식은 **폐기**한다. 공유는 공유 코드 + API만 사용한다.
|
||||
|
||||
| 종류 | `share_type` | 코드 생성 | URL 예시 | 설명 |
|
||||
|---|---|---|---|---|
|
||||
| 개인 덱 | `user` | nanoid 8자 (저장마다 고유) | `/d/x7Kp9m2Q` | 같은 카드 구성이라도 저장자마다 URL이 다름 |
|
||||
| 메타 덱·프리셋 | `preset` | 내용 해시 8자 (동일 구성 = 동일 URL) | `/d/a7f3b2c1` | 관리자 등록 공식 템플릿, 중복 저장 없음 |
|
||||
|
||||
브라우저 진입:
|
||||
- `/d/:shareCode` → `cardList.html?d=:shareCode` 리다이렉트 또는 동일 페이지에서 API 로드
|
||||
- `cardList.html`은 `?d=` 파라미터가 있으면 API에서 덱 복원
|
||||
|
||||
### 9.3 DB 저장 vs API 응답
|
||||
|
||||
**DB에 저장하는 최소 덱 본문 (`cards_json`):**
|
||||
```json
|
||||
[
|
||||
{ "number": "UA36BT/MCR-1-052", "count": 4, "key": true },
|
||||
{ "number": "EX14BT/MCR-2-011", "count": 2, "key": false }
|
||||
]
|
||||
```
|
||||
|
||||
**API 응답 시 서버가 `datas/<series>.js`를 lookup하여 풀 카드 정보를 합성한다.**
|
||||
|
||||
```json
|
||||
{
|
||||
"v": 1,
|
||||
"shareCode": "x7Kp9m2Q",
|
||||
"shareType": "user",
|
||||
"series": "ua36",
|
||||
"seriesName": "마크로스",
|
||||
"name": "블루 aggro",
|
||||
"totalCards": 50,
|
||||
"cards": [
|
||||
{
|
||||
"number": "UA36BT/MCR-1-052",
|
||||
"count": 4,
|
||||
"key": true,
|
||||
"imgSrc": "/ua36/UA36BT_MCR-1-052.png",
|
||||
"color": "Blue",
|
||||
"rarity": "SR",
|
||||
"trigger": null,
|
||||
"parallel": false
|
||||
}
|
||||
],
|
||||
"author": {
|
||||
"id": 12,
|
||||
"displayName": "닉네임",
|
||||
"avatarUrl": "/uploads/avatars/12.png"
|
||||
},
|
||||
"createdAt": "2026-06-19T12:00:00Z",
|
||||
"updatedAt": "2026-06-19T14:30:00Z",
|
||||
"viewCount": 42,
|
||||
"isPublic": true
|
||||
}
|
||||
```
|
||||
|
||||
비회원 임시 공유: `author`는 `null`, `expiresAt` 필드로 만료 관리.
|
||||
|
||||
### 9.4 DB 스키마 (초안)
|
||||
|
||||
#### `users`
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|---|---|---|
|
||||
| `id` | INTEGER PK | |
|
||||
| `email` | TEXT UNIQUE | 로그인 ID |
|
||||
| `username` | TEXT UNIQUE | 표시용 닉네임 |
|
||||
| `password_hash` | TEXT | bcrypt |
|
||||
| `display_name` | TEXT | 프로필 이름 |
|
||||
| `avatar_url` | TEXT | 프로필 썸네일 |
|
||||
| `default_deck_public` | INTEGER | 덱 저장 기본 공개 여부 (1=공개, 0=비공개) |
|
||||
| `skip_deck_public_prompt` | INTEGER | 저장 시 공개 확인 생략 (1=생략) |
|
||||
| `role` | TEXT | `user` \| `admin` |
|
||||
| `is_active` | INTEGER | 0=비활성, 1=활성 |
|
||||
| `created_at` | TEXT | 가입일 |
|
||||
| `updated_at` | TEXT | |
|
||||
|
||||
#### `decks`
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|---|---|---|
|
||||
| `id` | INTEGER PK | |
|
||||
| `share_code` | TEXT UNIQUE | URL에 쓰이는 8자 코드 |
|
||||
| `share_type` | TEXT | `user` \| `preset` |
|
||||
| `content_hash` | TEXT | preset 타입용. user 타입은 null 가능 |
|
||||
| `user_id` | INTEGER FK | null=비회원 임시 공유 |
|
||||
| `series` | TEXT | 예: `ua36` |
|
||||
| `name` | TEXT | 덱 이름 |
|
||||
| `cards_json` | TEXT | `[{number,count,key}]` JSON |
|
||||
| `is_public` | INTEGER | 0=비공개, 1=공개 |
|
||||
| `view_count` | INTEGER | 조회수 |
|
||||
| `expires_at` | TEXT | null=만료 없음 |
|
||||
| `created_at` | TEXT | 생성일 |
|
||||
| `updated_at` | TEXT | 최종 수정일 |
|
||||
|
||||
덱 1건 용량 추정: 카드 50장 × 약 60바이트 ≈ 3KB. 수만 건이어도 DB 부담은 경미하다.
|
||||
|
||||
### 9.5 API 엔드포인트 (초안)
|
||||
|
||||
#### 인증
|
||||
| Method | Path | 설명 |
|
||||
|---|---|---|
|
||||
| POST | `/api/v1/auth/register` | 회원가입 |
|
||||
| POST | `/api/v1/auth/login` | 로그인 → JWT |
|
||||
| GET | `/api/v1/auth/me` | 내 정보 |
|
||||
| PUT | `/api/v1/auth/me` | 프로필 수정 (이메일, 사용자명, 아바타, `defaultDeckPublic`, `skipDeckPublicPrompt`) |
|
||||
| POST | `/api/v1/auth/me/password` | 비밀번호 변경 |
|
||||
| GET | `/api/v1/auth/avatar/:userId` | 프로필 이미지 (PNG) |
|
||||
|
||||
#### 덱
|
||||
| Method | Path | 설명 |
|
||||
|---|---|---|
|
||||
| GET | `/api/v1/decks/:shareCode` | 덱 조회 (외부 연동 공식 계약) |
|
||||
| GET | `/api/v1/decks` | 내 덱 목록 (인증 필요) |
|
||||
| POST | `/api/v1/decks` | 덱 저장·공유 코드 발급 |
|
||||
| PUT | `/api/v1/decks/:shareCode` | 덱 수정 (소유자만) |
|
||||
| DELETE | `/api/v1/decks/:shareCode` | 덱 삭제 (소유자만) |
|
||||
|
||||
#### 관리자
|
||||
| Method | Path | 설명 |
|
||||
|---|---|---|
|
||||
| GET | `/api/v1/admin/users` | 회원 목록 |
|
||||
| GET | `/api/v1/admin/users/:id` | 회원 상세 |
|
||||
| PUT | `/api/v1/admin/users/:id` | 회원 정보·역할·활성 수정 |
|
||||
| POST | `/api/v1/admin/users/:id/reset-password` | 비밀번호 초기화 |
|
||||
| POST | `/api/v1/admin/decks/preset` | 메타 덱(프리셋) 등록 |
|
||||
|
||||
### 9.6 프론트 변경 (예정)
|
||||
- `script/auth-header.js`: 헤더 프로필 드롭다운 (로그인/회원가입, 내 덱 관리)
|
||||
- `login.html`, `signup.html`, `deckHistory.html`, `admin.html` 신규
|
||||
- `cardList.html`: `?cards=` 제거, `?d=` API 로드로 전환
|
||||
- `localStorage.selectedCards`: 편집 중 임시 저장만 (공유와 분리)
|
||||
|
||||
### 9.7 배포
|
||||
- `docker-compose.yml`: API만 (기존 웹서버 사용 시)
|
||||
- `docker-compose.prod.yml`: Nginx + API 풀스택 (NAS 권장)
|
||||
- `docs/deploy.md`: NAS 배포 상세 가이드
|
||||
- `docs/api-integration.md`: 옆 프로젝트 연동 가이드
|
||||
- `integrations/uadeck-deck-client.js`: 연동용 JS 클라이언트
|
||||
- `nginx/default.conf`: `/api/`, `/d/` 라우팅
|
||||
|
||||
26
docs/todo.md
26
docs/todo.md
@@ -7,12 +7,36 @@
|
||||
- `cardList.html`과 `result.html` 내부 스크립트 비중이 커서 기능 확장 시 유지보수 난도가 올라갈 수 있다.
|
||||
- 데이터 파일 네이밍과 샘플 파일(`datas/ua99 sample.js`, `datas/ua99 sample mini.js`) 처리 기준이 문서상 명확하지 않았다.
|
||||
|
||||
## v4.0.0 구현 완료 항목
|
||||
- [x] Docker API 서버 + DB (`users`, `decks` 테이블)
|
||||
- [x] 인증: 회원가입, 로그인, JWT, 헤더 프로필 드롭다운
|
||||
- [x] 덱 API: 저장, 조회, 공유 코드 (`GET /api/v1/decks/:shareCode`)
|
||||
- [x] `cardList.html`: `?d=` API 로드, 저장·공유 버튼
|
||||
- [x] `deckHistory.html`, `login.html`, `signup.html`, `preset.html`
|
||||
- [x] `admin.html`: 회원 목록·수정, 비밀번호 초기화
|
||||
- [x] `index.html` 버전 `v4.0.0`
|
||||
|
||||
## v4 후속 작업
|
||||
- [ ] NAS에 `docker compose -f docker-compose.prod.yml up -d` 배포
|
||||
- [ ] `.env` 설정 및 기존 리버스 프록시 연동
|
||||
- [ ] 옆 프로젝트에 `integrations/uadeck-deck-client.js` 적용
|
||||
- [ ] `cardList.html` 인라인 스크립트 모듈 분리
|
||||
- [ ] i18n 번역 키 추가 (auth, admin 문구)
|
||||
|
||||
## v4.0.0 최근 완료
|
||||
- [x] `profile.html`: 일반 사용자 프로필 화면 (아바타·닉네임·이메일·비밀번호)
|
||||
- [x] 헤더 드롭다운「내 프로필」링크
|
||||
- [x] 아바타 업로드 시 PNG 리사이즈 (`script/image-utils.js`) — JPEG 등 형식 깨짐 방지
|
||||
- [x] 아바타 URL `updatedAt` 캐시 무효화
|
||||
- [x] 내 덱 관리: `/d/코드` 경로 표시 제거
|
||||
- [x] `result.html` 하단 버튼 개편 + 공개 확인 모달 + 홈 이동
|
||||
- [x] 프로필·API: 덱 저장 공개 기본값 설정
|
||||
|
||||
## 다음 작업 제안
|
||||
- 원격 저장소에서 `Git LFS` 업로드가 정상 완료되었는지 확인한다.
|
||||
- 협업자가 있다면 히스토리 재작성 이후 동기화 방법을 공지한다.
|
||||
- `Git LFS` 도입 후에도 저장소 운영 비용이 크면 이미지 저장소 분리/CDN 이전을 재검토한다.
|
||||
- `cardList.html`의 인라인 스크립트를 별도 JS 모듈로 분리하는 리팩터링을 검토한다.
|
||||
- 존재하지 않는 페이지 링크를 정리하거나, 향후 구현 예정이면 문서에 상태를 명확히 표기한다.
|
||||
|
||||
## 판단 가이드
|
||||
- 신규 이미지 추가 관리가 목적이면 `Git LFS` 추적만으로도 충분하다.
|
||||
|
||||
144
docs/update.md
144
docs/update.md
@@ -1,5 +1,149 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## 2026-06-19 / v4.0.0
|
||||
|
||||
### 수행 작업
|
||||
- v4.0.0 회원·덱 API·공유 시스템 1차 구현
|
||||
- `api/` Express + SQLite 백엔드 (인증, 덱 CRUD, 관리자 API)
|
||||
- `docker-compose.yml`, `.env.example`, `nginx/uadeck.conf` 추가
|
||||
- 프론트: `script/api.js`, `script/auth-header.js`, `style/auth-header.css`
|
||||
- 신규 페이지: `login.html`, `signup.html`, `deckHistory.html`, `preset.html`, `admin.html`
|
||||
- `index.html` v4.0.0 + 프로필 헤더
|
||||
- `cardList.html`: `?cards=` 제거, `?d=` API 로드, 저장·공유 버튼
|
||||
- `result.html`: localStorage 기반, 프로필 헤더
|
||||
- `docs/spec.md` §9 API·DB 명세 반영
|
||||
|
||||
### 배포 방법
|
||||
1. `.env.example`을 `.env`로 복사 후 `JWT_SECRET`, `ADMIN_EMAIL`, `ADMIN_PASSWORD` 설정
|
||||
2. `docker compose up -d --build`
|
||||
3. 리버스 프록시에 `nginx/uadeck.conf` 규칙 적용 (`/api/`, `/d/`)
|
||||
|
||||
### 메모
|
||||
- 비회원도 저장·공유 가능 (30일 만료). 회원은 영구 저장.
|
||||
- 외부 연동: `GET /api/v1/decks/:shareCode`, 클라이언트 `integrations/uadeck-deck-client.js`
|
||||
|
||||
## 2026-06-19 / v4.0.0 덱 저장 UX
|
||||
|
||||
### 수행 작업
|
||||
- `cardList.html` 저장·공유 버튼 제거
|
||||
- `result.html` 하단 Back 제거 →「내 덱에 저장」「공유」버튼 추가
|
||||
- 저장·공유 시 Preview 이미지 PNG 함께 API 저장 (`preview_image`)
|
||||
- 덱 이름: 입력값 없으면 `시리즈명 YYYY-MM-DD`
|
||||
- `deckHistory.html` 미리보기 썸네일 표시
|
||||
|
||||
- 로그아웃 시 메인(`/`)으로 이동
|
||||
- `seedAdmin`: `ADMIN_EMAIL`로 먼저 가입한 경우 역할을 `admin`으로 승격 (기존에는 INSERT 실패로 관리자 미생성)
|
||||
- `auth-header.js`: 프로필 메뉴 열 때 `/auth/me`로 role 재동기화 (관리자 메뉴 미표시 수정)
|
||||
- 관리자 API: JWT role 대신 DB role 확인 (`createRequireAdmin`)
|
||||
|
||||
- `npm start` / `npm stop` / `npm run logs` 로컬 실행 명령 정리 (`package.json`)
|
||||
|
||||
- 관리자 덱 관리 썸네일, 회원 모달 개편 (아바타·비밀번호 분리 모달)
|
||||
- 본인 계정 역할/비활성 변경 서버·UI 차단, 표시 이름 필드 제거 (사용자명 사용)
|
||||
|
||||
## 2026-06-19 / v4.0.0 — 공개 덱·관리자 UI
|
||||
|
||||
### 수행 작업
|
||||
- 메타 덱: 관리자 프리셋 등록 폐기 → 사용자 `is_public` 공개 덱이 `/preset.html`(공개 덱)에 노출
|
||||
- 내 덱 저장 기본 비공개, 공유 시 공개, 내 덱 관리에서 공개/비공개 전환
|
||||
- 덱 이름 기본값: `YYYYMMDD_HHMMSS` (시리즈명 제외)
|
||||
- `admin.html` 레이아웃 개편 (`style/admin.css`), 프리셋 등록 탭 제거
|
||||
- `preset.html` 카드 그리드 + 필터 UI
|
||||
|
||||
## 2026-06-19 / v4.0.0 — 프로필·아바타
|
||||
|
||||
### 수행 작업
|
||||
- **썸네일(프로필 사진) 미적용 원인**: JPEG 등을 `.png`로 그대로 저장해 브라우저 표시 실패 → 업로드 전 `canvas`로 PNG 변환 (`script/image-utils.js`)
|
||||
- `profile.html` 신규: 일반 사용자가 이메일·사용자명·아바타·비밀번호 변경 (`PUT /auth/me`, `POST /auth/me/password`)
|
||||
- 헤더 드롭다운에「내 프로필」추가 (`script/auth-header.js`)
|
||||
- 아바타 URL에 `updatedAt` 쿼리로 캐시 무효화 (저장 후 즉시 반영)
|
||||
- `admin.html` 아바타 업로드도 동일 PNG 변환 적용
|
||||
- `script/api.js`: `updateProfile`, `changePassword` 추가
|
||||
- `docs/spec.md` 인증 API 엔드포인트 보강
|
||||
|
||||
### 확인 방법
|
||||
1. `npm stop && npm start` 로 API 재시작 (auth 라우트 변경 반영)
|
||||
2. http://localhost:8080/profile.html 에서 사진 업로드 →「프로필 저장」
|
||||
3. 헤더 썸네일 클릭 →「내 프로필」메뉴 확인
|
||||
|
||||
### 후속
|
||||
- 헤더 프로필 버튼: 로그인 시 아바타 옆 사용자명 표시 (`auth-header.js`, `auth-header.css`)
|
||||
|
||||
## 2026-06-19 / v4.0.0 — 덱 저장 UX·네비게이션
|
||||
|
||||
### 수행 작업
|
||||
- `deckHistory.html`: `/d/코드` 경로 표시 제거 (링크 복사 버튼 유지)
|
||||
- `result.html` 하단 버튼: `홈으로` / `이미지로 다운로드` / `내 덱으로 저장`
|
||||
- 저장 시「모두에게 공개할까요?」모달 +「다음부터 물어보지 않기」토글
|
||||
- `script/deck-prefs.js`: 공개 기본값·프롬프트 스킵 (localStorage + API 동기화)
|
||||
- `users` 테이블: `default_deck_public`, `skip_deck_public_prompt` 컬럼 추가
|
||||
- `profile.html`: 덱 저장 설정 (기본 공개, 물어보기 on/off)
|
||||
- 뒤로가기 루프 수정: `result.html` 편집 복귀 시 `history.back()`, `cardList` ← 홈 버튼
|
||||
|
||||
### 확인
|
||||
- API 변경 반영: `npm stop && npm start` (또는 `docker compose up -d --build`)
|
||||
|
||||
## 2026-06-19 / v4.0.0 — 레이아웃 통일·광고 공간
|
||||
|
||||
### 수행 작업
|
||||
- `style/app-shell.css` 추가: 헤더·본문 `80rem`(1280px, index `max-w-7xl`과 동일)
|
||||
- `deckHistory.html`, `preset.html`, `profile.html` 공통 셸 적용 (페이지 이동 시 헤더 너비 점프 제거)
|
||||
- 데스크톱: 본문 좌측 + 우측 300px 광고 레일(`app-ad-rail`), 모바일: 상단 리더보드 슬롯
|
||||
- 프로필 폼은 콘텐츠 영역 내 `max-width: 42rem` 유지
|
||||
- `auth-pages.css`, `admin.css` 너비도 80rem으로 통일
|
||||
|
||||
## 2026-06-19 / v4.0.0 — 덱 썸네일 수정
|
||||
|
||||
### 수행 작업
|
||||
- Preview 캡처 시 카드 이미지 로드 대기 + `cacheBust` 추가 (`result.html`)
|
||||
- 캡처 실패해도 덱 저장은 진행, 안내 메시지 표시
|
||||
- `deckHistory.html`: `UaDeckApi.resolveMediaUrl()`로 미리보기 URL 보정 (Live Server 등)
|
||||
- 이미지 로드 실패 시 플레이스홀더 표시
|
||||
- Docker 미리보기 저장 경로를 `./api/data/previews` 호스트 바인드 마운트로 변경
|
||||
- API `getPreviewUrl`: DB `preview_image` 컬럼도 참고
|
||||
|
||||
## 2026-06-19 / v4.0.0 UI — 내 덱 관리
|
||||
|
||||
### 수행 작업
|
||||
- `deckHistory.html` 카드 그리드 레이아웃으로 개편 (미리보기 이미지 상단 배치)
|
||||
- `style/deck-history.css` 추가: 상단 여백, 툴바, 카드 그리드 전용 스타일
|
||||
- 시리즈별 필터 드롭다운 + 덱 이름·시리즈 검색 입력 추가
|
||||
- Tailwind 미빌드 클래스 의존 제거
|
||||
|
||||
## 2026-06-19 / v4.0.0 UI — result 하단 버튼
|
||||
|
||||
### 수행 작업
|
||||
- `result.html` 하단「내 덱에 저장」「공유」버튼이 `style/output.css`에 없는 Tailwind 클래스(`bg-slate-700`, `bg-emerald-600`)로 인해 보이지 않던 문제 수정
|
||||
- 페이지 내 `<style>`에 `.result-footer-*`, `.result-btn-*` 전용 스타일 추가 (인증 페이지와 동일한 패턴)
|
||||
|
||||
## 2026-06-19 / v4.0.0 UI — 인증 페이지
|
||||
|
||||
### 수행 작업
|
||||
- `style/auth-pages.css` 추가: 폼 최대 너비 28rem, 상단 여백, index와 동일한 그라데이션·폰트
|
||||
- `login.html`, `signup.html` 레이아웃 개선 (Tailwind 미빌드 클래스 의존 제거)
|
||||
|
||||
## 2026-06-19 / v4.0.0 배포·연동 보강
|
||||
|
||||
### 수행 작업
|
||||
- `docker-compose.prod.yml`: Nginx + API 풀스택 (정적 파일 볼륨 마운트, 재빌드 불필요)
|
||||
- `nginx/default.conf`, `docs/deploy.md` NAS 배포 가이드
|
||||
- `integrations/uadeck-deck-client.js` 옆 프로젝트용 클라이언트
|
||||
- `docs/api-integration.md` API 연동 문서
|
||||
- `admin.html` 프리셋 등록 탭 추가
|
||||
|
||||
## 2026-06-19 / v4.0.0
|
||||
|
||||
### 수행 작업
|
||||
- v4 회원·덱 API·공유 방식 설계를 확정했다.
|
||||
- URL 쿼리(`?cards=`) 기반 공유는 폐기하고, 공유 코드 + `GET /api/v1/decks/:shareCode` API로 전환하기로 했다.
|
||||
- 개인 덱은 저장마다 고유 `share_code`, 메타 덱·프리셋은 내용 해시 기반 고정 코드를 사용한다.
|
||||
- DB 스키마 초안(`users`, `decks`)과 API 엔드포인트 목록을 `docs/spec.md` §9에 반영했다.
|
||||
- 의사결정 내용을 `docs/history.md`에 기록했다.
|
||||
|
||||
### 메모
|
||||
- 구현은 이 설계를 기준으로 진행한다. 실제 코드·Docker·화면 작업은 다음 단계다.
|
||||
- 옆 프로젝트는 API 계약만 맞추면 되며, 레거시 쿼리 호환은 필요 없다.
|
||||
|
||||
## 2026-04-19 / v3.1.0
|
||||
|
||||
### 수행 작업
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"editDeck": "← EDIT DECK",
|
||||
"deckPreview": "Deck Preview",
|
||||
"deckNameLabel": "Deck Name",
|
||||
"deckNamePlaceholder": "Series name used if empty",
|
||||
"deckNamePlaceholder": "YYYYMMDD_HHMMSS if empty",
|
||||
"exportStyle": "Export Style",
|
||||
"type1Title": "Type 1: Signature",
|
||||
"type1Desc": "Standard style highlighting Key Cards",
|
||||
@@ -31,13 +31,21 @@
|
||||
"exportNotice1": "* If image fails to generate, please check 'Pop-up' settings.",
|
||||
"exportNotice2": "* On iOS, you may need to long-press the image to save.",
|
||||
"generatingImage": "Generating Image...",
|
||||
"savingPreview": "Creating preview...",
|
||||
"savingToMyDeck": "Saving to My Decks...",
|
||||
"sharingDeck": "Creating share link...",
|
||||
"saveComplete": "Saved!",
|
||||
"shareComplete": "Ready to share!",
|
||||
"downloadComplete": "Download complete!",
|
||||
"downloadImage": "DOWNLOAD IMAGE",
|
||||
"saveToMyDeck": "Save to My Decks",
|
||||
"shareDeck": "Share",
|
||||
"keyCardLabel": "KEY CARD",
|
||||
"back": "BACK",
|
||||
"saveError": "Failed to save image",
|
||||
|
||||
"all": "All",
|
||||
"metaDeck": "Meta Deck",
|
||||
"metaDeck": "Public Decks",
|
||||
"titlePrompt": "Please select a title",
|
||||
"selectTitle": "Select Title",
|
||||
"deckName": "Deck Name",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"editDeck": "← デッキを編集",
|
||||
"deckPreview": "デッキプレビュー",
|
||||
"deckNameLabel": "デッキ名",
|
||||
"deckNamePlaceholder": "未入力時はシリーズ名を使用",
|
||||
"deckNamePlaceholder": "未入力時は YYYYMMDD_HHMMSS",
|
||||
"exportStyle": "書き出しスタイル",
|
||||
"type1Title": "タイプ 1: Signature",
|
||||
"type1Desc": "キーカードを強調する基本スタイル",
|
||||
@@ -31,13 +31,21 @@
|
||||
"exportNotice1": "* 画像が生成されない場合は「ポップアップ許可」を確認してください。",
|
||||
"exportNotice2": "* iOS環境では画像を長押しして保存する必要がある場合があります。",
|
||||
"generatingImage": "画像生成中...",
|
||||
"savingPreview": "プレビュー生成中...",
|
||||
"savingToMyDeck": "マイデックに保存中...",
|
||||
"sharingDeck": "共有リンク作成中...",
|
||||
"saveComplete": "保存完了!",
|
||||
"shareComplete": "共有準備完了!",
|
||||
"downloadComplete": "ダウンロード完了!",
|
||||
"downloadImage": "画像を保存する",
|
||||
"saveToMyDeck": "マイデックに保存",
|
||||
"shareDeck": "共有",
|
||||
"keyCardLabel": "KEY CARD",
|
||||
"back": "戻る",
|
||||
"saveError": "画像の保存に失敗しました",
|
||||
|
||||
"all": "全て",
|
||||
"metaDeck": "メタデッキ",
|
||||
"metaDeck": "公開デッキ",
|
||||
"titlePrompt": "タイトルを選択してください",
|
||||
"selectTitle": "タイトルを選択",
|
||||
"deckName": "デッキ名",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"editDeck": "← EDIT DECK",
|
||||
"deckPreview": "Deck Preview",
|
||||
"deckNameLabel": "Deck Name",
|
||||
"deckNamePlaceholder": "미입력 시 시리즈명 사용",
|
||||
"deckNamePlaceholder": "미입력 시 YYYYMMDD_HHMMSS",
|
||||
"exportStyle": "Export Style",
|
||||
"type1Title": "Type 1: Signature",
|
||||
"type1Desc": "키 카드를 강조하는 기본 스타일",
|
||||
@@ -31,13 +31,21 @@
|
||||
"exportNotice1": "* 이미지가 생성되지 않을 경우 브라우저의 '팝업 허용'을 확인해주세요.",
|
||||
"exportNotice2": "* iOS 환경에서는 이미지를 길게 눌러 저장해야 할 수도 있습니다.",
|
||||
"generatingImage": "이미지 생성 중...",
|
||||
"savingPreview": "미리보기 생성 중...",
|
||||
"savingToMyDeck": "내 덱에 저장 중...",
|
||||
"sharingDeck": "공유 링크 생성 중...",
|
||||
"saveComplete": "저장 완료!",
|
||||
"shareComplete": "공유 준비 완료!",
|
||||
"downloadComplete": "다운로드 완료!",
|
||||
"downloadImage": "DOWNLOAD IMAGE",
|
||||
"saveToMyDeck": "내 덱에 저장",
|
||||
"shareDeck": "공유",
|
||||
"keyCardLabel": "KEY CARD",
|
||||
"back": "BACK",
|
||||
"saveError": "이미지 저장 실패",
|
||||
|
||||
"all": "전체",
|
||||
"metaDeck": "메타 덱",
|
||||
"metaDeck": "공개 덱",
|
||||
"titlePrompt": "작품을 선택해주세요",
|
||||
"selectTitle": "작품 선택",
|
||||
"deckName": "덱 이름",
|
||||
|
||||
18
index.html
18
index.html
@@ -8,7 +8,9 @@
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=Noto+Sans+KR:wght@400;700&display=swap" rel="stylesheet" />
|
||||
|
||||
<script src="/script/navigation.js"></script>
|
||||
<title>UADECK V3.1.0 - Union Arena Deck Builder</title>
|
||||
<script src="/script/api.js"></script>
|
||||
<link rel="stylesheet" href="./style/auth-header.css" />
|
||||
<title>UADECK V4.0.0 - Union Arena Deck Builder</title>
|
||||
|
||||
<style>
|
||||
body {
|
||||
@@ -62,14 +64,17 @@
|
||||
</div>
|
||||
<span class="text-xl font-extrabold tracking-tight text-slate-800">
|
||||
UADECK
|
||||
<span class="text-blue-600">v3.1.0</span>
|
||||
<span class="text-blue-600">v4.0.0</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="language-selector flex gap-1 p-1 bg-slate-100 rounded-xl">
|
||||
<button type="button" onclick="changeLanguage('ko')" id="lang-ko" class="rounded-lg px-4 py-1.5 text-xs font-bold transition-all cursor-pointer text-slate-500 hover:text-slate-800">KO</button>
|
||||
<button type="button" onclick="changeLanguage('ja')" id="lang-ja" class="rounded-lg px-4 py-1.5 text-xs font-bold transition-all cursor-pointer text-slate-500 hover:text-slate-800">JA</button>
|
||||
<button type="button" onclick="changeLanguage('en')" id="lang-en" class="rounded-lg px-4 py-1.5 text-xs font-bold transition-all cursor-pointer text-slate-500 hover:text-slate-800">EN</button>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="language-selector flex gap-1 p-1 bg-slate-100 rounded-xl">
|
||||
<button type="button" onclick="changeLanguage('ko')" id="lang-ko" class="rounded-lg px-4 py-1.5 text-xs font-bold transition-all cursor-pointer text-slate-500 hover:text-slate-800">KO</button>
|
||||
<button type="button" onclick="changeLanguage('ja')" id="lang-ja" class="rounded-lg px-4 py-1.5 text-xs font-bold transition-all cursor-pointer text-slate-500 hover:text-slate-800">JA</button>
|
||||
<button type="button" onclick="changeLanguage('en')" id="lang-en" class="rounded-lg px-4 py-1.5 text-xs font-bold transition-all cursor-pointer text-slate-500 hover:text-slate-800">EN</button>
|
||||
</div>
|
||||
<div id="auth-header-slot"></div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -169,5 +174,6 @@
|
||||
window.addEventListener('popstate', () => loadSeriesList());
|
||||
loadSeriesList();
|
||||
</script>
|
||||
<script src="/script/auth-header.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
28
integrations/README.md
Normal file
28
integrations/README.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# 연동 예제
|
||||
|
||||
`integrations/example.html`을 브라우저에서 열거나, 배포 후 아래 URL로 테스트할 수 있습니다.
|
||||
|
||||
## 옆 프로젝트에 복사할 파일
|
||||
|
||||
```
|
||||
integrations/uadeck-deck-client.js
|
||||
```
|
||||
|
||||
## 최소 연동 코드
|
||||
|
||||
```javascript
|
||||
const client = new UaDeckDeckClient({
|
||||
baseUrl: 'https://uniare.sori.studio/api/v1',
|
||||
imageBase: 'https://uniare.sori.studio',
|
||||
});
|
||||
|
||||
// UADECK 공유 링크에서 덱 로드
|
||||
const deck = await client.fetchFromUrl('https://uniare.sori.studio/d/SHARE_CODE');
|
||||
|
||||
// 카드 사용
|
||||
for (const card of deck.cards) {
|
||||
console.log(card.number, 'x', card.count, card.imageUrl);
|
||||
}
|
||||
```
|
||||
|
||||
자세한 내용은 `docs/api-integration.md`를 참고하세요.
|
||||
58
integrations/example.html
Normal file
58
integrations/example.html
Normal file
@@ -0,0 +1,58 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>UADECK API 연동 예제</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; max-width: 640px; margin: 2rem auto; padding: 0 1rem; }
|
||||
input { width: 100%; padding: 0.5rem; margin: 0.5rem 0 1rem; box-sizing: border-box; }
|
||||
button { padding: 0.5rem 1rem; cursor: pointer; }
|
||||
pre { background: #f1f5f9; padding: 1rem; overflow: auto; font-size: 12px; border-radius: 8px; }
|
||||
.card { display: flex; gap: 0.5rem; align-items: center; margin: 0.25rem 0; font-size: 13px; }
|
||||
.card img { width: 40px; height: 56px; object-fit: cover; border-radius: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>UADECK API 연동 예제</h1>
|
||||
<p>공유 링크 또는 shareCode로 덱을 불러옵니다.</p>
|
||||
|
||||
<label>API Base URL</label>
|
||||
<input type="text" id="baseUrl" value="/api/v1" />
|
||||
|
||||
<label>공유 링크 또는 코드</label>
|
||||
<input type="text" id="shareInput" placeholder="https://uniare.sori.studio/d/x7Kp9m2Q 또는 x7Kp9m2Q" />
|
||||
|
||||
<button type="button" id="loadBtn">덱 불러오기</button>
|
||||
|
||||
<h2>결과</h2>
|
||||
<pre id="jsonOut">—</pre>
|
||||
<div id="cardList"></div>
|
||||
|
||||
<script src="./uadeck-deck-client.js"></script>
|
||||
<script>
|
||||
document.getElementById('loadBtn').addEventListener('click', async () => {
|
||||
const baseUrl = document.getElementById('baseUrl').value.trim();
|
||||
const input = document.getElementById('shareInput').value.trim();
|
||||
const client = new UaDeckDeckClient({ baseUrl });
|
||||
|
||||
try {
|
||||
const deck = await client.fetchFromUrl(input);
|
||||
document.getElementById('jsonOut').textContent = JSON.stringify(deck, null, 2);
|
||||
document.getElementById('cardList').innerHTML = deck.cards
|
||||
.map(
|
||||
(c) => `
|
||||
<div class="card">
|
||||
<img src="${c.imageUrl}" alt="" onerror="this.style.display='none'" />
|
||||
<span>${c.number} ×${c.count}${c.key ? ' ★' : ''}</span>
|
||||
</div>`,
|
||||
)
|
||||
.join('');
|
||||
} catch (err) {
|
||||
document.getElementById('jsonOut').textContent = 'Error: ' + err.message;
|
||||
document.getElementById('cardList').innerHTML = '';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
130
integrations/uadeck-deck-client.js
Normal file
130
integrations/uadeck-deck-client.js
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* UADECK 덱 API 클라이언트 (옆 프로젝트 연동용)
|
||||
*
|
||||
* @example
|
||||
* const client = new UaDeckDeckClient({ baseUrl: 'https://uniare.sori.studio/api/v1' });
|
||||
* const deck = await client.fetchDeck('x7Kp9m2Q');
|
||||
* deck.cards.forEach(c => console.log(c.number, c.count, c.imgSrc));
|
||||
*/
|
||||
class UaDeckDeckClient {
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {string} options.baseUrl API 베이스 (예: https://uniare.sori.studio/api/v1)
|
||||
* @param {string} [options.imageBase] 이미지 호스트 (기본: baseUrl에서 /api/v1 제거)
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
this.baseUrl = (options.baseUrl || '/api/v1').replace(/\/$/, '');
|
||||
this.imageBase = options.imageBase || this.baseUrl.replace(/\/api\/v1$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 공유 코드로 덱 조회
|
||||
* @param {string} shareCode
|
||||
* @returns {Promise<UaDeckDeck>}
|
||||
*/
|
||||
async fetchDeck(shareCode) {
|
||||
const res = await fetch(`${this.baseUrl}/decks/${encodeURIComponent(shareCode)}`);
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || `덱 조회 실패 (${res.status})`);
|
||||
}
|
||||
return this._normalizeDeck(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* URL에서 shareCode 추출
|
||||
* @param {string} url 예: https://uniare.sori.studio/d/x7Kp9m2Q 또는 ?d=x7Kp9m2Q
|
||||
* @returns {string|null}
|
||||
*/
|
||||
static parseShareCode(url) {
|
||||
try {
|
||||
const u = new URL(url, 'https://placeholder.local');
|
||||
const fromQuery = u.searchParams.get('d');
|
||||
if (fromQuery) return fromQuery;
|
||||
const match = u.pathname.match(/\/d\/([A-Za-z0-9]+)/);
|
||||
return match ? match[1] : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* URL 또는 코드로 덱 로드
|
||||
* @param {string} urlOrCode
|
||||
* @returns {Promise<UaDeckDeck>}
|
||||
*/
|
||||
async fetchFromUrl(urlOrCode) {
|
||||
const code = UaDeckDeckClient.parseShareCode(urlOrCode) || urlOrCode;
|
||||
return this.fetchDeck(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 이미지 전체 URL
|
||||
* @param {string} imgSrc API 응답의 imgSrc (예: /ua36/UA36BT_MCR-1-052.png)
|
||||
* @returns {string}
|
||||
*/
|
||||
imageUrl(imgSrc) {
|
||||
return `${this.imageBase}/images${imgSrc.startsWith('/') ? imgSrc : `/${imgSrc}`}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} raw API 응답
|
||||
* @returns {UaDeckDeck}
|
||||
*/
|
||||
_normalizeDeck(raw) {
|
||||
return {
|
||||
shareCode: raw.shareCode,
|
||||
shareType: raw.shareType,
|
||||
series: raw.series,
|
||||
seriesName: raw.seriesName,
|
||||
name: raw.name,
|
||||
totalCards: raw.totalCards,
|
||||
author: raw.author,
|
||||
createdAt: raw.createdAt,
|
||||
updatedAt: raw.updatedAt,
|
||||
viewCount: raw.viewCount,
|
||||
cards: (raw.cards || []).map((c) => ({
|
||||
number: c.number,
|
||||
count: c.count,
|
||||
key: Boolean(c.key),
|
||||
imgSrc: c.imgSrc,
|
||||
imageUrl: this.imageUrl(c.imgSrc),
|
||||
color: c.color,
|
||||
rarity: c.rarity,
|
||||
trigger: c.trigger,
|
||||
parallel: Boolean(c.parallel),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { UaDeckDeckClient };
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
window.UaDeckDeckClient = UaDeckDeckClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {object} UaDeckCard
|
||||
* @property {string} number
|
||||
* @property {number} count
|
||||
* @property {boolean} key
|
||||
* @property {string} imgSrc
|
||||
* @property {string} imageUrl
|
||||
* @property {string} color
|
||||
* @property {string} rarity
|
||||
* @property {string|null} trigger
|
||||
* @property {boolean} parallel
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} UaDeckDeck
|
||||
* @property {string} shareCode
|
||||
* @property {string} shareType
|
||||
* @property {string} series
|
||||
* @property {string} seriesName
|
||||
* @property {string} name
|
||||
* @property {number} totalCards
|
||||
* @property {UaDeckCard[]} cards
|
||||
*/
|
||||
66
login.html
Normal file
66
login.html
Normal file
@@ -0,0 +1,66 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="stylesheet" href="./style/output.css" />
|
||||
<link rel="stylesheet" href="./style/auth-pages.css" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=Noto+Sans+KR:wght@400;700&display=swap" rel="stylesheet" />
|
||||
<title>로그인 - UADECK</title>
|
||||
</head>
|
||||
<body class="auth-page">
|
||||
<nav class="auth-nav">
|
||||
<div class="auth-nav-inner">
|
||||
<a href="/" class="auth-brand">
|
||||
<img src="/images/assets/apple-touch-icon.png" alt="UA Logo" />
|
||||
<span class="auth-brand-text">UADECK <span>v4.0.0</span></span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="auth-main">
|
||||
<header class="auth-header">
|
||||
<h1>로그인</h1>
|
||||
<p>덱을 저장하고 관리하려면<br />계정으로 로그인하세요.</p>
|
||||
</header>
|
||||
|
||||
<form id="login-form" class="auth-card">
|
||||
<div class="auth-field">
|
||||
<label class="auth-label" for="email">이메일</label>
|
||||
<input type="email" id="email" required class="auth-input" placeholder="you@example.com" autocomplete="email" />
|
||||
</div>
|
||||
<div class="auth-field">
|
||||
<label class="auth-label" for="password">비밀번호</label>
|
||||
<input type="password" id="password" required class="auth-input" placeholder="••••••••" autocomplete="current-password" />
|
||||
</div>
|
||||
<p id="error-msg" class="auth-error hidden"></p>
|
||||
<button type="submit" class="auth-submit">로그인</button>
|
||||
</form>
|
||||
|
||||
<p class="auth-footer">
|
||||
계정이 없으신가요?
|
||||
<a href="/signup.html">회원가입</a>
|
||||
</p>
|
||||
</main>
|
||||
|
||||
<script src="/script/api.js"></script>
|
||||
<script>
|
||||
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const errEl = document.getElementById('error-msg');
|
||||
errEl.classList.add('hidden');
|
||||
try {
|
||||
const email = document.getElementById('email').value;
|
||||
const password = document.getElementById('password').value;
|
||||
const { token, user } = await UaDeckApi.login(email, password);
|
||||
UaDeckApi.saveAuth(token, user);
|
||||
const redirect = new URLSearchParams(location.search).get('redirect') || '/';
|
||||
location.href = redirect;
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message;
|
||||
errEl.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
11
nginx/Dockerfile
Normal file
11
nginx/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# 정적 사이트 (docker-compose build context = 프로젝트 루트)
|
||||
COPY index.html cardList.html result.html login.html signup.html deckHistory.html preset.html admin.html /usr/share/nginx/html/
|
||||
COPY script /usr/share/nginx/html/script
|
||||
COPY style /usr/share/nginx/html/style
|
||||
COPY i18n /usr/share/nginx/html/i18n
|
||||
COPY datas /usr/share/nginx/html/datas
|
||||
COPY images /usr/share/nginx/html/images
|
||||
35
nginx/default.conf
Normal file
35
nginx/default.conf
Normal file
@@ -0,0 +1,35 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
client_max_body_size 2m;
|
||||
|
||||
# 공유 링크: /d/x7Kp9m2Q → cardList?d=
|
||||
location ~ ^/d/([A-Za-z0-9]+)$ {
|
||||
return 302 /cardList.html?d=$1;
|
||||
}
|
||||
|
||||
# API → 백엔드 컨테이너
|
||||
location /api/ {
|
||||
proxy_pass http://uadeck-api:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# 정적 자산 캐시
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|webp|ico|svg|woff2?)$ {
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
16
nginx/uadeck.conf
Normal file
16
nginx/uadeck.conf
Normal file
@@ -0,0 +1,16 @@
|
||||
# /d/:shareCode → cardList 리다이렉트
|
||||
# 리버스 프록시(Nginx, Caddy, Traefik)에 아래 규칙을 추가하세요.
|
||||
|
||||
location ~ ^/d/([A-Za-z0-9]+)$ {
|
||||
return 302 /cardList.html?d=$1;
|
||||
}
|
||||
|
||||
# API 프록시 (정적 사이트와 함께 사용)
|
||||
location /api/ {
|
||||
proxy_pass http://uadeck-api:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
@@ -2,5 +2,13 @@
|
||||
"dependencies": {
|
||||
"@tailwindcss/cli": "^4.1.12",
|
||||
"tailwindcss": "^4.1.12"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "docker compose -f docker-compose.prod.yml up -d --build && printf '\\n✅ UADECK 실행 중 → http://localhost:8080\\n 종료: npm stop\\n 로그: npm run logs\\n\\n'",
|
||||
"stop": "docker compose -f docker-compose.prod.yml down",
|
||||
"logs": "docker compose -f docker-compose.prod.yml logs -f",
|
||||
"dev": "docker compose -f docker-compose.prod.yml up --build",
|
||||
"api:dev": "node --env-file=.env --watch api/src/index.js",
|
||||
"dev:down": "npm run stop"
|
||||
}
|
||||
}
|
||||
|
||||
156
preset.html
Normal file
156
preset.html
Normal file
@@ -0,0 +1,156 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-4516420168710424" crossorigin="anonymous"></script>
|
||||
<link rel="stylesheet" href="./style/output.css" />
|
||||
<link rel="stylesheet" href="./style/app-shell.css" />
|
||||
<link rel="stylesheet" href="./style/auth-header.css" />
|
||||
<link rel="stylesheet" href="./style/deck-history.css" />
|
||||
<title>공개 덱 - UADECK</title>
|
||||
</head>
|
||||
<body class="app-page deck-history-page">
|
||||
<nav class="app-nav">
|
||||
<div class="app-nav-inner">
|
||||
<a href="/" class="app-brand">UADECK <span>v4.0.0</span></a>
|
||||
<div id="auth-header-slot"></div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="app-ad-top">
|
||||
<div class="app-ad-slot app-ad-slot--leaderboard" id="ad-leaderboard" aria-label="광고"></div>
|
||||
</div>
|
||||
|
||||
<div class="app-shell">
|
||||
<div class="app-body-layout">
|
||||
<main class="app-main deck-history-main">
|
||||
<header class="deck-history-header">
|
||||
<h1>공개 덱</h1>
|
||||
<p>사용자가 공개로 전환한 덱 목록입니다. 내 덱 관리에서「공개 전환」하면 여기에 표시됩니다.</p>
|
||||
</header>
|
||||
|
||||
<div id="deck-toolbar" class="deck-history-toolbar hidden">
|
||||
<input type="search" id="deck-search" class="deck-search-input" placeholder="덱 이름·시리즈·작성자 검색..." autocomplete="off" />
|
||||
<select id="deck-series-filter" class="deck-series-select" aria-label="시리즈 필터">
|
||||
<option value="">모든 시리즈</option>
|
||||
</select>
|
||||
<span id="deck-count" class="deck-count-label"></span>
|
||||
</div>
|
||||
|
||||
<div id="preset-list">
|
||||
<div class="deck-status-message">불러오는 중...</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside class="app-ad-rail" aria-label="광고">
|
||||
<div class="app-ad-slot app-ad-slot--skyscraper" id="ad-sidebar"></div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/script/api.js"></script>
|
||||
<script src="/script/auth-header.js"></script>
|
||||
<script>
|
||||
/** @type {Array<object>} */
|
||||
let allDecks = [];
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function populateSeriesFilter(decks) {
|
||||
const select = document.getElementById('deck-series-filter');
|
||||
const current = select.value;
|
||||
const seriesMap = new Map();
|
||||
decks.forEach((d) => {
|
||||
if (!seriesMap.has(d.series)) seriesMap.set(d.series, d.series_name || d.series);
|
||||
});
|
||||
const sorted = [...seriesMap.entries()].sort((a, b) => String(a[1]).localeCompare(String(b[1]), 'ko'));
|
||||
select.innerHTML =
|
||||
'<option value="">모든 시리즈</option>' +
|
||||
sorted.map(([key, label]) => `<option value="${escapeHtml(key)}">${escapeHtml(label)}</option>`).join('');
|
||||
if (current && seriesMap.has(current)) select.value = current;
|
||||
}
|
||||
|
||||
function getFilteredDecks() {
|
||||
const query = document.getElementById('deck-search').value.trim().toLowerCase();
|
||||
const series = document.getElementById('deck-series-filter').value;
|
||||
return allDecks.filter((d) => {
|
||||
if (series && d.series !== series) return false;
|
||||
if (!query) return true;
|
||||
const haystack = [d.name, d.series_name, d.series, d.author, d.share_code].filter(Boolean).join(' ').toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}
|
||||
|
||||
function renderPreviewHtml(d, title) {
|
||||
if (!d.preview_url) {
|
||||
return '<div class="deck-card-preview-placeholder">미리보기 없음</div>';
|
||||
}
|
||||
const src = UaDeckApi.resolveMediaUrl(d.preview_url);
|
||||
const version = d.updated_at ? `?v=${encodeURIComponent(d.updated_at)}` : '';
|
||||
return `<img src="${escapeHtml(src + version)}" alt="${title}" loading="lazy" class="deck-card-preview-img" />`;
|
||||
}
|
||||
|
||||
function renderDeckCard(d) {
|
||||
const title = escapeHtml(d.name || d.series_name || d.series);
|
||||
const seriesLabel = escapeHtml(d.series_name || d.series);
|
||||
const date = new Date(d.updated_at).toLocaleDateString('ko-KR');
|
||||
return `
|
||||
<a href="/cardList.html?series=${escapeHtml(d.series)}&seriesName=${encodeURIComponent(d.series_name || '')}&d=${escapeHtml(d.share_code)}" class="deck-card" style="text-decoration:none;color:inherit">
|
||||
<div class="deck-card-preview">${renderPreviewHtml(d, title)}</div>
|
||||
<div class="deck-card-body">
|
||||
<span class="deck-card-series">${seriesLabel}</span>
|
||||
<h2 class="deck-card-title">${title}</h2>
|
||||
<div class="deck-card-meta">${escapeHtml(d.author)} · 조회 ${d.view_count} · ${date}</div>
|
||||
</div>
|
||||
</a>`;
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const list = document.getElementById('preset-list');
|
||||
const countEl = document.getElementById('deck-count');
|
||||
const filtered = getFilteredDecks();
|
||||
countEl.textContent = `${filtered.length} / ${allDecks.length}개`;
|
||||
|
||||
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(renderDeckCard).join('')}</div>`;
|
||||
}
|
||||
|
||||
async function loadPublicDecks() {
|
||||
const list = document.getElementById('preset-list');
|
||||
const toolbar = document.getElementById('deck-toolbar');
|
||||
try {
|
||||
const { presets } = await UaDeckApi.getPresets();
|
||||
allDecks = presets;
|
||||
if (!presets.length) {
|
||||
toolbar.classList.add('hidden');
|
||||
list.innerHTML = '<div class="deck-status-message">공개된 덱이 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
toolbar.classList.remove('hidden');
|
||||
populateSeriesFilter(presets);
|
||||
renderList();
|
||||
} catch (err) {
|
||||
toolbar.classList.add('hidden');
|
||||
list.innerHTML = `<div class="deck-status-message error">${escapeHtml(err.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadPublicDecks();
|
||||
document.getElementById('deck-search').addEventListener('input', renderList);
|
||||
document.getElementById('deck-series-filter').addEventListener('change', renderList);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
419
profile.html
Normal file
419
profile.html
Normal file
@@ -0,0 +1,419 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-4516420168710424" crossorigin="anonymous"></script>
|
||||
<link rel="stylesheet" href="./style/output.css" />
|
||||
<link rel="stylesheet" href="./style/app-shell.css" />
|
||||
<link rel="stylesheet" href="./style/auth-header.css" />
|
||||
<link rel="stylesheet" href="./style/profile.css" />
|
||||
<title>내 프로필 - UADECK</title>
|
||||
</head>
|
||||
<body class="app-page profile-page">
|
||||
<nav class="app-nav">
|
||||
<div class="app-nav-inner">
|
||||
<a href="/" class="app-brand">UADECK <span>v4.0.0</span></a>
|
||||
<div id="auth-header-slot"></div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="app-ad-top">
|
||||
<div class="app-ad-slot app-ad-slot--leaderboard" id="ad-leaderboard" aria-label="광고"></div>
|
||||
</div>
|
||||
|
||||
<div class="app-shell">
|
||||
<div class="app-body-layout">
|
||||
<main class="app-main profile-content">
|
||||
<header class="profile-header">
|
||||
<h1>내 프로필</h1>
|
||||
<p>프로필 사진, 사용자명, 이메일, 비밀번호를 변경할 수 있습니다.</p>
|
||||
</header>
|
||||
|
||||
<div id="profile-loading" class="profile-card">
|
||||
<div class="profile-body">
|
||||
<p style="margin: 0; color: #64748b; font-size: 0.875rem">불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="profile-form-wrap" class="profile-card hidden">
|
||||
<div class="profile-hero">
|
||||
<div class="profile-avatar-wrap">
|
||||
<img id="profile-avatar" class="profile-avatar" alt="프로필 사진" />
|
||||
<button type="button" id="profile-avatar-btn" class="profile-avatar-btn" title="사진 변경">📷</button>
|
||||
<input type="file" id="profile-avatar-file" accept="image/*" class="hidden" />
|
||||
</div>
|
||||
<div class="profile-hero-text">
|
||||
<h2 id="profile-hero-name">—</h2>
|
||||
<p id="profile-hero-email">—</p>
|
||||
<button type="button" id="profile-avatar-remove" class="profile-avatar-remove hidden">
|
||||
프로필 사진 제거
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-body">
|
||||
<div class="profile-field">
|
||||
<label class="profile-label" for="profile-email">이메일</label>
|
||||
<input type="email" id="profile-email" class="profile-input" autocomplete="email" />
|
||||
</div>
|
||||
<div class="profile-field">
|
||||
<label class="profile-label" for="profile-username">사용자명</label>
|
||||
<input type="text" id="profile-username" class="profile-input" autocomplete="username" />
|
||||
</div>
|
||||
|
||||
<button type="button" id="profile-save" class="profile-btn profile-btn-primary" disabled>
|
||||
프로필 저장
|
||||
</button>
|
||||
<p id="profile-error" class="profile-error hidden"></p>
|
||||
<p id="profile-success" class="profile-success hidden"></p>
|
||||
|
||||
<div class="profile-divider"></div>
|
||||
|
||||
<h3 class="profile-section-title">덱 저장 설정</h3>
|
||||
<p class="profile-section-desc">「내 덱으로 저장」시 공개 여부 기본값과 확인 방식을 설정합니다.</p>
|
||||
|
||||
<div class="profile-toggle-row">
|
||||
<div>
|
||||
<div class="profile-toggle-label">기본 공개</div>
|
||||
<div class="profile-toggle-hint">저장 시 기본으로 공개 덱에 노출</div>
|
||||
</div>
|
||||
<button type="button" id="pref-public-toggle" class="profile-toggle" aria-pressed="true">
|
||||
<span id="pref-public-circle" class="profile-toggle-circle is-on"></span>
|
||||
</button>
|
||||
<input type="checkbox" id="pref-public-check" class="hidden" checked />
|
||||
</div>
|
||||
|
||||
<div class="profile-toggle-row">
|
||||
<div>
|
||||
<div class="profile-toggle-label">저장 시 공개 여부 물어보기</div>
|
||||
<div class="profile-toggle-hint">끄면 설정한 기본값으로 바로 저장</div>
|
||||
</div>
|
||||
<button type="button" id="pref-ask-toggle" class="profile-toggle" aria-pressed="true">
|
||||
<span id="pref-ask-circle" class="profile-toggle-circle is-on"></span>
|
||||
</button>
|
||||
<input type="checkbox" id="pref-ask-check" class="hidden" checked />
|
||||
</div>
|
||||
|
||||
<div class="profile-divider"></div>
|
||||
|
||||
<button type="button" id="profile-open-password" class="profile-btn profile-btn-outline">
|
||||
비밀번호 변경
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside class="app-ad-rail" aria-label="광고">
|
||||
<div class="app-ad-slot app-ad-slot--skyscraper" id="ad-sidebar"></div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="password-modal" class="profile-modal">
|
||||
<div class="profile-modal-box">
|
||||
<h3>비밀번호 변경</h3>
|
||||
<div class="profile-field">
|
||||
<label class="profile-label" for="password-current">현재 비밀번호</label>
|
||||
<input type="password" id="password-current" class="profile-input" autocomplete="current-password" />
|
||||
</div>
|
||||
<div class="profile-field">
|
||||
<label class="profile-label" for="password-new">새 비밀번호</label>
|
||||
<input type="password" id="password-new" class="profile-input" autocomplete="new-password" />
|
||||
</div>
|
||||
<div class="profile-field">
|
||||
<label class="profile-label" for="password-confirm">새 비밀번호 확인</label>
|
||||
<input type="password" id="password-confirm" class="profile-input" autocomplete="new-password" />
|
||||
</div>
|
||||
<p id="password-error" class="profile-error hidden"></p>
|
||||
<div class="profile-modal-actions">
|
||||
<button type="button" id="password-cancel" class="profile-btn profile-btn-outline">취소</button>
|
||||
<button type="button" id="password-save" class="profile-btn profile-btn-primary">변경</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/script/api.js"></script>
|
||||
<script src="/script/deck-prefs.js"></script>
|
||||
<script src="/script/image-utils.js"></script>
|
||||
<script src="/script/auth-header.js"></script>
|
||||
<script>
|
||||
const DEFAULT_AVATAR =
|
||||
'data:image/svg+xml,' +
|
||||
encodeURIComponent(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#94a3b8"><path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"/></svg>',
|
||||
);
|
||||
|
||||
let currentUser = null;
|
||||
let pendingAvatarImage = null;
|
||||
let pendingRemoveAvatar = false;
|
||||
/** @type {{ email: string, username: string, hasAvatar: boolean }} */
|
||||
let baseline = { email: '', username: '', hasAvatar: false };
|
||||
|
||||
/**
|
||||
* 프로필 토글 UI
|
||||
* @param {string} toggleId
|
||||
* @param {string} circleId
|
||||
* @param {string} checkboxId
|
||||
* @param {boolean} on
|
||||
*/
|
||||
function setProfileToggle(toggleId, circleId, checkboxId, on) {
|
||||
const toggle = document.getElementById(toggleId);
|
||||
const circle = document.getElementById(circleId);
|
||||
const checkbox = document.getElementById(checkboxId);
|
||||
if (!toggle || !circle || !checkbox) return;
|
||||
checkbox.checked = on;
|
||||
toggle.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||
toggle.classList.toggle('is-on', on);
|
||||
circle.classList.toggle('is-on', on);
|
||||
}
|
||||
|
||||
/**
|
||||
* 덱 저장 설정 UI 반영
|
||||
* @param {object} user
|
||||
*/
|
||||
function fillDeckPrefs(user) {
|
||||
const prefs = UaDeckPrefs.getPrefs(user);
|
||||
setProfileToggle('pref-public-toggle', 'pref-public-circle', 'pref-public-check', prefs.defaultPublic);
|
||||
setProfileToggle(
|
||||
'pref-ask-toggle',
|
||||
'pref-ask-circle',
|
||||
'pref-ask-check',
|
||||
!prefs.skipPrompt,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 덱 저장 설정 저장
|
||||
*/
|
||||
async function persistDeckPrefs() {
|
||||
const prefs = {
|
||||
defaultPublic: document.getElementById('pref-public-check').checked,
|
||||
skipPrompt: !document.getElementById('pref-ask-check').checked,
|
||||
};
|
||||
await UaDeckPrefs.savePrefs(prefs);
|
||||
const cached = UaDeckApi.getCachedUser();
|
||||
if (cached) {
|
||||
cached.defaultDeckPublic = prefs.defaultPublic;
|
||||
cached.skipDeckPublicPrompt = prefs.skipPrompt;
|
||||
UaDeckApi.saveAuth(UaDeckApi.getToken(), cached);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장 버튼 활성화 여부 갱신
|
||||
*/
|
||||
function updateSaveButton() {
|
||||
const email = document.getElementById('profile-email').value.trim();
|
||||
const username = document.getElementById('profile-username').value.trim();
|
||||
const dirty =
|
||||
email !== baseline.email ||
|
||||
username !== baseline.username ||
|
||||
pendingAvatarImage !== null ||
|
||||
(pendingRemoveAvatar && baseline.hasAvatar);
|
||||
const btn = document.getElementById('profile-save');
|
||||
btn.disabled = !dirty;
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장 기준값 설정 (저장 성공·초기 로드 후)
|
||||
* @param {object} user
|
||||
*/
|
||||
function setBaseline(user) {
|
||||
baseline = {
|
||||
email: user.email || '',
|
||||
username: user.username || '',
|
||||
hasAvatar: Boolean(user.avatarUrl),
|
||||
};
|
||||
pendingAvatarImage = null;
|
||||
pendingRemoveAvatar = false;
|
||||
updateSaveButton();
|
||||
}
|
||||
|
||||
/**
|
||||
* 아바타 URL (캐시 무효화 포함)
|
||||
* @param {object|null} user
|
||||
* @returns {string}
|
||||
*/
|
||||
function resolveAvatarSrc(user) {
|
||||
const raw = user?.avatarUrl;
|
||||
if (!raw) return DEFAULT_AVATAR;
|
||||
let url = UaDeckApi.resolveMediaUrl(raw) || raw;
|
||||
if (user?.updatedAt && !url.startsWith('data:')) {
|
||||
url += (url.includes('?') ? '&' : '?') + 't=' + encodeURIComponent(user.updatedAt);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* 폼에 사용자 정보 반영
|
||||
* @param {object} user
|
||||
*/
|
||||
function fillForm(user) {
|
||||
document.getElementById('profile-hero-name').textContent = user.username || '—';
|
||||
document.getElementById('profile-hero-email').textContent = user.email || '';
|
||||
document.getElementById('profile-email').value = user.email || '';
|
||||
document.getElementById('profile-username').value = user.username || '';
|
||||
document.getElementById('profile-avatar').src = resolveAvatarSrc(user);
|
||||
document.getElementById('profile-avatar-remove').classList.toggle('hidden', !user.avatarUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* 메시지 표시
|
||||
* @param {'error'|'success'} type
|
||||
* @param {string} text
|
||||
*/
|
||||
function showMessage(type, text) {
|
||||
const errEl = document.getElementById('profile-error');
|
||||
const okEl = document.getElementById('profile-success');
|
||||
errEl.classList.add('hidden');
|
||||
okEl.classList.add('hidden');
|
||||
if (!text) return;
|
||||
const el = type === 'error' ? errEl : okEl;
|
||||
el.textContent = text;
|
||||
el.classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (!UaDeckApi.getToken()) {
|
||||
location.href = '/login.html?redirect=' + encodeURIComponent('/profile.html');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { user } = await UaDeckApi.getMe();
|
||||
currentUser = user;
|
||||
UaDeckApi.saveAuth(UaDeckApi.getToken(), user);
|
||||
fillForm(user);
|
||||
setBaseline(user);
|
||||
fillDeckPrefs(user);
|
||||
document.getElementById('profile-loading').classList.add('hidden');
|
||||
document.getElementById('profile-form-wrap').classList.remove('hidden');
|
||||
} catch (err) {
|
||||
document.getElementById('profile-loading').innerHTML =
|
||||
'<div class="profile-body"><p class="profile-error" style="margin:0">' +
|
||||
(err.message || '프로필을 불러올 수 없습니다.') +
|
||||
'</p></div>';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('profile-email').addEventListener('input', updateSaveButton);
|
||||
document.getElementById('profile-username').addEventListener('input', updateSaveButton);
|
||||
|
||||
document.getElementById('pref-public-toggle').addEventListener('click', async () => {
|
||||
const checkbox = document.getElementById('pref-public-check');
|
||||
setProfileToggle(
|
||||
'pref-public-toggle',
|
||||
'pref-public-circle',
|
||||
'pref-public-check',
|
||||
!checkbox.checked,
|
||||
);
|
||||
await persistDeckPrefs();
|
||||
});
|
||||
|
||||
document.getElementById('pref-ask-toggle').addEventListener('click', async () => {
|
||||
const checkbox = document.getElementById('pref-ask-check');
|
||||
setProfileToggle('pref-ask-toggle', 'pref-ask-circle', 'pref-ask-check', !checkbox.checked);
|
||||
await persistDeckPrefs();
|
||||
});
|
||||
|
||||
document.getElementById('profile-avatar-btn').addEventListener('click', () => {
|
||||
document.getElementById('profile-avatar-file').click();
|
||||
});
|
||||
|
||||
document.getElementById('profile-avatar-file').addEventListener('change', async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
pendingAvatarImage = await UaImageUtils.resizeImageToPng(file);
|
||||
pendingRemoveAvatar = false;
|
||||
document.getElementById('profile-avatar').src = pendingAvatarImage;
|
||||
document.getElementById('profile-avatar-remove').classList.remove('hidden');
|
||||
showMessage('success', '사진이 선택되었습니다.「프로필 저장」을 눌러 적용하세요.');
|
||||
updateSaveButton();
|
||||
} catch (err) {
|
||||
showMessage('error', err.message || '이미지 처리에 실패했습니다.');
|
||||
}
|
||||
e.target.value = '';
|
||||
});
|
||||
|
||||
document.getElementById('profile-avatar-remove').addEventListener('click', () => {
|
||||
pendingAvatarImage = null;
|
||||
pendingRemoveAvatar = true;
|
||||
document.getElementById('profile-avatar').src = DEFAULT_AVATAR;
|
||||
document.getElementById('profile-avatar-remove').classList.add('hidden');
|
||||
showMessage('success', '사진 제거가 예약되었습니다.「프로필 저장」을 눌러 적용하세요.');
|
||||
updateSaveButton();
|
||||
});
|
||||
|
||||
document.getElementById('profile-save').addEventListener('click', async () => {
|
||||
showMessage('error', '');
|
||||
showMessage('success', '');
|
||||
const body = {
|
||||
email: document.getElementById('profile-email').value.trim(),
|
||||
username: document.getElementById('profile-username').value.trim(),
|
||||
};
|
||||
if (pendingAvatarImage) body.avatarImage = pendingAvatarImage;
|
||||
if (pendingRemoveAvatar) body.removeAvatar = true;
|
||||
|
||||
try {
|
||||
const { user } = await UaDeckApi.updateProfile(body);
|
||||
currentUser = user;
|
||||
UaDeckApi.saveAuth(UaDeckApi.getToken(), user);
|
||||
fillForm(user);
|
||||
setBaseline(user);
|
||||
if (window.UaDeckAuth?.refresh) await window.UaDeckAuth.refresh();
|
||||
showMessage('success', '프로필이 저장되었습니다.');
|
||||
} catch (err) {
|
||||
showMessage('error', err.message || '저장에 실패했습니다.');
|
||||
}
|
||||
});
|
||||
|
||||
const passwordModal = document.getElementById('password-modal');
|
||||
|
||||
document.getElementById('profile-open-password').addEventListener('click', () => {
|
||||
document.getElementById('password-current').value = '';
|
||||
document.getElementById('password-new').value = '';
|
||||
document.getElementById('password-confirm').value = '';
|
||||
document.getElementById('password-error').classList.add('hidden');
|
||||
passwordModal.classList.add('open');
|
||||
});
|
||||
|
||||
document.getElementById('password-cancel').addEventListener('click', () => {
|
||||
passwordModal.classList.remove('open');
|
||||
});
|
||||
|
||||
document.getElementById('password-save').addEventListener('click', async () => {
|
||||
const errEl = document.getElementById('password-error');
|
||||
errEl.classList.add('hidden');
|
||||
const current = document.getElementById('password-current').value;
|
||||
const pw = document.getElementById('password-new').value;
|
||||
const confirm = document.getElementById('password-confirm').value;
|
||||
if (pw.length < 8) {
|
||||
errEl.textContent = '비밀번호는 8자 이상이어야 합니다.';
|
||||
errEl.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
if (pw !== confirm) {
|
||||
errEl.textContent = '새 비밀번호가 일치하지 않습니다.';
|
||||
errEl.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await UaDeckApi.changePassword(current, pw);
|
||||
passwordModal.classList.remove('open');
|
||||
showMessage('success', '비밀번호가 변경되었습니다.');
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message || '비밀번호 변경에 실패했습니다.';
|
||||
errEl.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
passwordModal.addEventListener('click', (e) => {
|
||||
if (e.target === passwordModal) passwordModal.classList.remove('open');
|
||||
});
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
704
result.html
704
result.html
@@ -5,6 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-4516420168710424" crossorigin="anonymous"></script>
|
||||
<link rel="stylesheet" href="./style/output.css" />
|
||||
<link rel="stylesheet" href="./style/auth-header.css" />
|
||||
<script src="/script/api.js"></script>
|
||||
<title>Deck Result - UA Deck Builder</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script>
|
||||
<style>
|
||||
@@ -127,21 +129,226 @@
|
||||
gap: 20px !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.result-footer-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(8px);
|
||||
border-top: 1px solid #e2e8f0;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.result-footer-inner {
|
||||
max-width: 80rem;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.result-btn-home {
|
||||
background: #64748b;
|
||||
}
|
||||
|
||||
.result-btn-home:hover {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
.result-btn {
|
||||
flex: 1;
|
||||
padding: 1rem 0.5rem;
|
||||
border: none;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, transform 0.1s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.result-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.result-btn-save {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.result-btn-save:hover {
|
||||
background: #047857;
|
||||
}
|
||||
|
||||
.result-btn-download {
|
||||
background: #2563eb;
|
||||
box-shadow: 0 10px 15px -3px rgba(37, 99, 235, 0.25);
|
||||
}
|
||||
|
||||
.result-btn-download:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.save-deck-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 110;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
}
|
||||
|
||||
.save-deck-modal.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.save-deck-modal-box {
|
||||
width: 100%;
|
||||
max-width: 22rem;
|
||||
background: #fff;
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.15);
|
||||
}
|
||||
|
||||
.save-deck-modal-box h3 {
|
||||
margin: 0 0 0.375rem;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.save-deck-modal-desc {
|
||||
margin: 0 0 1.25rem;
|
||||
font-size: 0.8125rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.save-deck-toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.save-deck-toggle-row + .save-deck-toggle-row {
|
||||
border-top: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.save-deck-toggle-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.save-deck-toggle-hint {
|
||||
font-size: 0.6875rem;
|
||||
color: #94a3b8;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.save-deck-modal-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.save-deck-modal-actions .result-btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.result-btn-modal-cancel {
|
||||
background: #fff;
|
||||
color: #475569;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.result-btn-modal-cancel:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.save-loading-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.save-loading-box {
|
||||
width: min(20rem, calc(100% - 2rem));
|
||||
padding: 1.75rem 1.5rem;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 1.25rem;
|
||||
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.save-loading-message {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
text-align: center;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.save-loading-bar-track {
|
||||
height: 0.5rem;
|
||||
background: #e2e8f0;
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.save-loading-bar-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: linear-gradient(90deg, #2563eb, #3b82f6);
|
||||
border-radius: 9999px;
|
||||
transition: width 0.35s ease;
|
||||
}
|
||||
|
||||
.save-loading-submessage {
|
||||
margin: 0.75rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
text-align: center;
|
||||
min-height: 1.125rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.result-footer-inner {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app" class="min-h-screen pb-24">
|
||||
<div id="loading-screen" class="fixed inset-0 z-[100] bg-white/80 backdrop-blur-sm flex items-center justify-center" style="display: none">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-4 border-blue-500 border-t-transparent"></div>
|
||||
<p id="i18n-loadingMsg" class="text-sm font-bold text-slate-600">이미지 생성 중...</p>
|
||||
<div id="loading-screen" class="save-loading-overlay">
|
||||
<div class="save-loading-box">
|
||||
<p id="loading-message" class="save-loading-message"></p>
|
||||
<div class="save-loading-bar-track">
|
||||
<div id="loading-bar-fill" class="save-loading-bar-fill"></div>
|
||||
</div>
|
||||
<p id="loading-submessage" class="save-loading-submessage"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sticky top-0 z-50 h-16 bg-white/80 backdrop-blur-md border-b px-4 py-3 flex justify-between items-center">
|
||||
<button id="i18n-editDeck" onclick="goBackToCardList()" class="font-bold text-slate-600 hover:text-blue-600 transition-colors">← EDIT DECK</button>
|
||||
<div id="i18n-previewTitle" class="font-bold text-slate-800 uppercase tracking-wider">Deck Preview</div>
|
||||
<div id="auth-header-slot"></div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-[1400px] mx-auto p-4 flex flex-col lg:flex-row gap-6">
|
||||
@@ -193,14 +400,47 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="fixed bottom-0 left-0 right-0 p-4 bg-white/80 backdrop-blur-md border-t z-50">
|
||||
<div class="max-w-7xl mx-auto flex gap-3">
|
||||
<button id="i18n-btnBack" onclick="goBackToCardList()" class="flex-1 bg-slate-100 hover:bg-slate-200 text-slate-600 py-4 rounded-2xl font-bold transition-all">BACK</button>
|
||||
<button id="i18n-btnDownload" onclick="saveAsImage()" class="flex-[2] bg-blue-600 hover:bg-blue-700 text-white py-4 rounded-2xl font-bold shadow-lg shadow-blue-200 transition-all active:scale-95">DOWNLOAD IMAGE</button>
|
||||
<div class="result-footer-bar">
|
||||
<div class="result-footer-inner">
|
||||
<button type="button" id="btn-go-home" onclick="goHome()" class="result-btn result-btn-home">홈으로</button>
|
||||
<button type="button" id="i18n-btnDownload" onclick="saveAsImage()" class="result-btn result-btn-download">이미지로 다운로드</button>
|
||||
<button type="button" id="btn-save-deck" onclick="saveToMyDeck()" class="result-btn result-btn-save">내 덱으로 저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="save-deck-modal" class="save-deck-modal">
|
||||
<div class="save-deck-modal-box">
|
||||
<h3>모두에게 공개할까요?</h3>
|
||||
<p class="save-deck-modal-desc">공개하면「공개 덱」목록에 표시됩니다.</p>
|
||||
<div class="save-deck-toggle-row">
|
||||
<div>
|
||||
<div class="save-deck-toggle-label">공개</div>
|
||||
<div class="save-deck-toggle-hint">기본값: 예</div>
|
||||
</div>
|
||||
<button type="button" id="save-public-toggle" class="relative inline-flex h-6 w-11 items-center rounded-full bg-blue-600 transition-colors">
|
||||
<span id="save-public-circle" class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform translate-x-6"></span>
|
||||
</button>
|
||||
<input type="checkbox" id="save-public-check" class="hidden" checked />
|
||||
</div>
|
||||
<div class="save-deck-toggle-row">
|
||||
<div>
|
||||
<div class="save-deck-toggle-label">다음부터 물어보지 않기</div>
|
||||
<div class="save-deck-toggle-hint">선택한 공개 여부가 기본값이 됩니다</div>
|
||||
</div>
|
||||
<button type="button" id="save-skip-toggle" class="relative inline-flex h-6 w-11 items-center rounded-full bg-slate-200 transition-colors">
|
||||
<span id="save-skip-circle" class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform translate-x-1"></span>
|
||||
</button>
|
||||
<input type="checkbox" id="save-skip-check" class="hidden" />
|
||||
</div>
|
||||
<div class="save-deck-modal-actions">
|
||||
<button type="button" id="save-deck-cancel" class="result-btn result-btn-modal-cancel">취소</button>
|
||||
<button type="button" id="save-deck-confirm" class="result-btn result-btn-save">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/script/deck-prefs.js"></script>
|
||||
<script type="module">
|
||||
let currentStyle = 'type1';
|
||||
let selectedCards = [];
|
||||
@@ -209,8 +449,10 @@
|
||||
// 전역 함수로 등록 (onclick 이벤트 대응)
|
||||
window.changeStyle = changeStyle;
|
||||
window.saveAsImage = saveAsImage;
|
||||
window.saveToMyDeck = saveToMyDeck;
|
||||
window.toggleHQ = toggleHQ;
|
||||
window.goBackToCardList = goBackToCardList;
|
||||
window.goHome = goHome;
|
||||
|
||||
let translations = {};
|
||||
|
||||
@@ -227,18 +469,20 @@
|
||||
console.error('i18n 로드 실패:', e);
|
||||
}
|
||||
// 2. 카드 데이터 로드 및 렌더링
|
||||
let cardsParam = urlParams.get('cards') || localStorage.getItem('selectedCards');
|
||||
let cardsParam = localStorage.getItem('selectedCards');
|
||||
seriesKey = urlParams.get('series') || localStorage.getItem('selectedSeries');
|
||||
|
||||
if (cardsParam && seriesKey) {
|
||||
try {
|
||||
// 데이터 로드 시 decodeURIComponent 적용
|
||||
selectedCards = JSON.parse(decodeURIComponent(cardsParam));
|
||||
renderResult();
|
||||
} catch (e) {
|
||||
console.error('Data parsing error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
bindToggle('save-public-toggle', 'save-public-circle', 'save-public-check');
|
||||
bindToggle('save-skip-toggle', 'save-skip-circle', 'save-skip-check');
|
||||
});
|
||||
|
||||
function applyTranslationsToUI() {
|
||||
@@ -261,9 +505,9 @@
|
||||
setEl('i18n-hqDesc', 'hqDesc');
|
||||
setEl('i18n-notice1', 'exportNotice1');
|
||||
setEl('i18n-notice2', 'exportNotice2');
|
||||
setEl('i18n-btnBack', 'back');
|
||||
setEl('i18n-btnDownload', 'downloadImage');
|
||||
setEl('i18n-loadingMsg', 'generatingImage');
|
||||
setEl('btn-save-deck', 'saveToMyDeck', false);
|
||||
setEl('btn-go-home', 'goHome', false);
|
||||
|
||||
const deckInput = document.getElementById('deckName');
|
||||
if (deckInput) deckInput.placeholder = translations.deckNamePlaceholder || "";
|
||||
@@ -333,7 +577,6 @@
|
||||
<div class="mt-2 text-[10px] font-mono text-slate-400">${card.Number}</div>
|
||||
`;
|
||||
return div;
|
||||
return div;
|
||||
}
|
||||
|
||||
function changeStyle(style) {
|
||||
@@ -361,18 +604,74 @@
|
||||
circle.style.transform = cb.checked ? 'translateX(20px)' : 'translateX(0px)';
|
||||
}
|
||||
|
||||
async function saveAsImage() {
|
||||
const screen = document.getElementById('loading-screen');
|
||||
screen.style.display = 'flex';
|
||||
function getSeriesName() {
|
||||
const raw = new URLSearchParams(window.location.search).get('seriesName') || '';
|
||||
try {
|
||||
return decodeURIComponent(raw) || seriesKey;
|
||||
} catch {
|
||||
return raw || seriesKey;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장용 덱 이름 (입력값 없으면 YYYYMMDD_HHMMSS)
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatDeckTimestamp(date = new Date()) {
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}_${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
function getDeckSaveName() {
|
||||
const input = document.getElementById('deckName').value.trim();
|
||||
if (input) return input;
|
||||
return formatDeckTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* API 저장용 카드 페이로드
|
||||
* @returns {object[]}
|
||||
*/
|
||||
function getCardsPayload() {
|
||||
return selectedCards
|
||||
.filter((c) => c.count > 0)
|
||||
.map((c) => ({
|
||||
number: c.Number,
|
||||
count: c.count,
|
||||
key: Boolean(c.key),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 미리보기 영역 내 이미지 로드 대기
|
||||
* @param {HTMLElement} container
|
||||
*/
|
||||
async function waitForImages(container) {
|
||||
const imgs = Array.from(container.querySelectorAll('img'));
|
||||
await Promise.all(
|
||||
imgs.map(
|
||||
(img) =>
|
||||
new Promise((resolve) => {
|
||||
if (img.complete && img.naturalWidth > 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const done = () => resolve();
|
||||
img.addEventListener('load', done, { once: true });
|
||||
img.addEventListener('error', done, { once: true });
|
||||
setTimeout(done, 8000);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 미리보기 영역 PNG 캡처
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function capturePreviewPng() {
|
||||
const area = document.getElementById('resultArea');
|
||||
const hq = document.getElementById('highQuality').checked;
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const seriesName = urlParams.get('seriesName') || '';
|
||||
|
||||
const deckNameInput = document.getElementById('deckName').value.trim();
|
||||
const deckName = deckNameInput || seriesName;
|
||||
|
||||
const originalStyle = area.getAttribute('style') || '';
|
||||
const gridElements = area.querySelectorAll('div[class*="grid"]');
|
||||
const originalGrids = Array.from(gridElements).map((el) => el.className);
|
||||
@@ -393,80 +692,325 @@
|
||||
allCardWrappers.forEach((wrapper) => {
|
||||
const box = wrapper.querySelector('.card-image-box');
|
||||
if (box) {
|
||||
// 기존에 입혀진 모든 ring 관련 클래스나 style 제거
|
||||
box.style.boxShadow = 'none';
|
||||
box.style.outline = 'none';
|
||||
box.style.boxShadow = 'none';
|
||||
box.style.outline = 'none';
|
||||
if (box.classList.contains('key-card-highlight')) {
|
||||
box.classList.add('key-card-border');
|
||||
} else {
|
||||
box.classList.add('capture-safe-border');
|
||||
}
|
||||
}
|
||||
if (wrapper.classList.contains('w-48') || wrapper.classList.contains('lg:w-56')) {
|
||||
wrapper.style.width = '240px';
|
||||
}
|
||||
});
|
||||
|
||||
if (box.classList.contains('key-card-highlight')) {
|
||||
box.classList.add('key-card-border'); // 우리가 만든 인디고 보더 적용
|
||||
} else {
|
||||
box.classList.add('capture-safe-border'); // 일반 연한 보더 적용
|
||||
}
|
||||
}
|
||||
await waitForImages(area);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
const scale = hq ? 2 : 1;
|
||||
const originalDisplay = area.style.display;
|
||||
area.style.display = 'inline-block';
|
||||
|
||||
// Type 1 상단 키 카드 너비 고정
|
||||
if (wrapper.classList.contains('w-48') || wrapper.classList.contains('lg:w-56')) {
|
||||
wrapper.style.width = '240px';
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
const scale = hq ? 2 : 1;
|
||||
|
||||
// 캡처 직전 임시로 너비와 여백을 고정하여 중앙 정렬 유도
|
||||
const originalDisplay = area.style.display;
|
||||
area.style.display = 'inline-block'; // 내용물만큼만 너비를 잡도록 설정
|
||||
|
||||
const dataUrl = await htmlToImage.toPng(area, {
|
||||
pixelRatio: scale,
|
||||
backgroundColor: '#ffffff',
|
||||
// 캔버스 자체에 여백을 명시적으로 추가
|
||||
canvasWidth: (area.offsetWidth + 80) * scale,
|
||||
canvasHeight: (area.offsetHeight + 80) * scale,
|
||||
try {
|
||||
return await htmlToImage.toPng(area, {
|
||||
pixelRatio: scale,
|
||||
backgroundColor: '#ffffff',
|
||||
cacheBust: true,
|
||||
canvasWidth: (area.offsetWidth + 80) * scale,
|
||||
canvasHeight: (area.offsetHeight + 80) * scale,
|
||||
});
|
||||
|
||||
area.style.display = originalDisplay;
|
||||
|
||||
const now = new Date();
|
||||
const YYYY = now.getFullYear();
|
||||
const MM = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const DD = String(now.getDate()).padStart(2, '0');
|
||||
const hh = String(now.getHours()).padStart(2, '0');
|
||||
const mm = String(now.getMinutes()).padStart(2, '0');
|
||||
const ss = String(now.getSeconds()).padStart(2, '0');
|
||||
|
||||
const timestamp = `${YYYY}${MM}${DD}_${hh}${mm}${ss}`;
|
||||
const fileNameStr = deckName.trim() ? `uadeck_${deckName}_${timestamp}` : `uadeck_${timestamp}`;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.download = `${fileNameStr}.png`;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
} catch (e) {
|
||||
console.error('Capture Error:', e);
|
||||
alert('이미지 저장 실패');
|
||||
} finally {
|
||||
// 5. 스타일 복구
|
||||
area.style.display = originalDisplay;
|
||||
area.setAttribute('style', originalStyle);
|
||||
gridElements.forEach((el, i) => {
|
||||
el.className = originalGrids[i];
|
||||
});
|
||||
allCardWrappers.forEach((wrapper) => {
|
||||
wrapper.style.width = '';
|
||||
const box = wrapper.querySelector('.card-image-box');
|
||||
if (box) {
|
||||
// box.classList.remove('key-card-border', 'capture-safe-border');
|
||||
}
|
||||
});
|
||||
screen.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장·공유 로딩 UI
|
||||
* @param {string} message
|
||||
* @returns {{ setProgress: Function, setSubmessage: Function, complete: Function, hide: Function }}
|
||||
*/
|
||||
function beginSaveLoading(message) {
|
||||
const screen = document.getElementById('loading-screen');
|
||||
const msgEl = document.getElementById('loading-message');
|
||||
const subEl = document.getElementById('loading-submessage');
|
||||
const fillEl = document.getElementById('loading-bar-fill');
|
||||
let autoTimer = null;
|
||||
let current = 0;
|
||||
|
||||
const stopAuto = () => {
|
||||
if (autoTimer) {
|
||||
clearInterval(autoTimer);
|
||||
autoTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const setProgress = (percent, nextMessage) => {
|
||||
current = Math.max(current, percent);
|
||||
fillEl.style.width = `${Math.min(100, current)}%`;
|
||||
if (nextMessage) msgEl.textContent = nextMessage;
|
||||
};
|
||||
|
||||
const startAuto = (cap) => {
|
||||
stopAuto();
|
||||
autoTimer = setInterval(() => {
|
||||
if (current >= cap) return;
|
||||
current += 1;
|
||||
fillEl.style.width = `${current}%`;
|
||||
}, 120);
|
||||
};
|
||||
|
||||
msgEl.textContent = message;
|
||||
subEl.textContent = '';
|
||||
fillEl.style.width = '0%';
|
||||
current = 0;
|
||||
screen.style.display = 'flex';
|
||||
|
||||
return {
|
||||
setProgress,
|
||||
setSubmessage: (text) => {
|
||||
subEl.textContent = text;
|
||||
},
|
||||
startAuto,
|
||||
stopAuto,
|
||||
complete: async (doneMessage) => {
|
||||
stopAuto();
|
||||
setProgress(100, doneMessage);
|
||||
await new Promise((r) => setTimeout(r, 450));
|
||||
screen.style.display = 'none';
|
||||
},
|
||||
hide: () => {
|
||||
stopAuto();
|
||||
screen.style.display = 'none';
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* i18n 헬퍼
|
||||
* @param {string} key
|
||||
* @param {string} fallback
|
||||
* @returns {string}
|
||||
*/
|
||||
function t(key, fallback) {
|
||||
return translations[key] || fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* 토글 UI 상태 설정
|
||||
* @param {string} toggleId
|
||||
* @param {string} circleId
|
||||
* @param {string} checkboxId
|
||||
* @param {boolean} on
|
||||
*/
|
||||
function setToggleState(toggleId, circleId, checkboxId, on) {
|
||||
const toggle = document.getElementById(toggleId);
|
||||
const circle = document.getElementById(circleId);
|
||||
const checkbox = document.getElementById(checkboxId);
|
||||
if (!toggle || !circle || !checkbox) return;
|
||||
checkbox.checked = on;
|
||||
toggle.classList.toggle('bg-blue-600', on);
|
||||
toggle.classList.toggle('bg-slate-200', !on);
|
||||
circle.classList.toggle('translate-x-6', on);
|
||||
circle.classList.toggle('translate-x-1', !on);
|
||||
}
|
||||
|
||||
/**
|
||||
* 토글 클릭 바인딩
|
||||
* @param {string} toggleId
|
||||
* @param {string} circleId
|
||||
* @param {string} checkboxId
|
||||
*/
|
||||
function bindToggle(toggleId, circleId, checkboxId) {
|
||||
const toggle = document.getElementById(toggleId);
|
||||
if (!toggle) return;
|
||||
toggle.addEventListener('click', () => {
|
||||
const checkbox = document.getElementById(checkboxId);
|
||||
setToggleState(toggleId, circleId, checkboxId, !checkbox.checked);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장 시 공개 여부 확인 (설정·모달)
|
||||
* @returns {Promise<boolean|null>} null이면 취소
|
||||
*/
|
||||
function resolveIsPublicForSave() {
|
||||
const user = window.UaDeckApi?.getCachedUser?.() || null;
|
||||
const prefs = window.UaDeckPrefs.getPrefs(user);
|
||||
if (prefs.skipPrompt) {
|
||||
return Promise.resolve(prefs.defaultPublic);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const modal = document.getElementById('save-deck-modal');
|
||||
setToggleState(
|
||||
'save-public-toggle',
|
||||
'save-public-circle',
|
||||
'save-public-check',
|
||||
true,
|
||||
);
|
||||
setToggleState(
|
||||
'save-skip-toggle',
|
||||
'save-skip-circle',
|
||||
'save-skip-check',
|
||||
false,
|
||||
);
|
||||
modal.classList.add('open');
|
||||
|
||||
const cleanup = () => {
|
||||
modal.classList.remove('open');
|
||||
confirmBtn.removeEventListener('click', onConfirm);
|
||||
cancelBtn.removeEventListener('click', onCancel);
|
||||
modal.removeEventListener('click', onBackdrop);
|
||||
};
|
||||
|
||||
const onConfirm = async () => {
|
||||
const isPublic = document.getElementById('save-public-check').checked;
|
||||
const skip = document.getElementById('save-skip-check').checked;
|
||||
cleanup();
|
||||
if (skip) {
|
||||
await window.UaDeckPrefs.savePrefs({
|
||||
defaultPublic: isPublic,
|
||||
skipPrompt: true,
|
||||
});
|
||||
}
|
||||
resolve(isPublic);
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
cleanup();
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
const onBackdrop = (e) => {
|
||||
if (e.target === modal) onCancel();
|
||||
};
|
||||
|
||||
const confirmBtn = document.getElementById('save-deck-confirm');
|
||||
const cancelBtn = document.getElementById('save-deck-cancel');
|
||||
confirmBtn.addEventListener('click', onConfirm);
|
||||
cancelBtn.addEventListener('click', onCancel);
|
||||
modal.addEventListener('click', onBackdrop);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 덱 API 저장 실행
|
||||
* @param {boolean} isPublic
|
||||
*/
|
||||
async function executeDeckSave(isPublic) {
|
||||
const cards = getCardsPayload();
|
||||
if (!cards.length) {
|
||||
alert('저장할 카드가 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const loader = beginSaveLoading(
|
||||
t('savingToMyDeck', '내 덱으로 저장 중...'),
|
||||
);
|
||||
try {
|
||||
loader.setProgress(8, t('savingPreview', '미리보기 생성 중...'));
|
||||
loader.startAuto(52);
|
||||
let previewImage = null;
|
||||
try {
|
||||
previewImage = await capturePreviewPng();
|
||||
} catch (captureErr) {
|
||||
console.warn('Preview capture failed:', captureErr);
|
||||
}
|
||||
loader.stopAuto();
|
||||
loader.setProgress(58, t('savingToMyDeck', '내 덱으로 저장 중...'));
|
||||
loader.startAuto(88);
|
||||
const deck = await UaDeckApi.saveDeck({
|
||||
series: seriesKey,
|
||||
seriesName: getSeriesName(),
|
||||
name: getDeckSaveName(),
|
||||
cards,
|
||||
previewImage,
|
||||
isPublic,
|
||||
});
|
||||
loader.stopAuto();
|
||||
const visibility = isPublic ? '공개' : '비공개';
|
||||
if (!previewImage) {
|
||||
await loader.complete(t('saveComplete', '저장 완료'));
|
||||
alert(
|
||||
`덱이 ${visibility}로 저장되었습니다.\n(미리보기 이미지 생성 실패)\n${deck.name}`,
|
||||
);
|
||||
} else {
|
||||
await loader.complete(t('saveComplete', '저장 완료'));
|
||||
alert(`덱이 ${visibility}로 저장되었습니다.\n${deck.name}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
loader.hide();
|
||||
alert(e.message || '저장에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveToMyDeck() {
|
||||
if (!window.UaDeckApi?.getToken()) {
|
||||
const redirect = encodeURIComponent(location.pathname + location.search);
|
||||
location.href = `/login.html?redirect=${redirect}`;
|
||||
return;
|
||||
}
|
||||
const isPublic = await resolveIsPublicForSave();
|
||||
if (isPublic === null) return;
|
||||
await executeDeckSave(isPublic);
|
||||
}
|
||||
|
||||
async function saveAsImage() {
|
||||
const loader = beginSaveLoading(t('generatingImage', '이미지 생성 중...'));
|
||||
|
||||
const deckName = getDeckSaveName();
|
||||
|
||||
try {
|
||||
loader.setProgress(10);
|
||||
loader.startAuto(85);
|
||||
const dataUrl = await capturePreviewPng();
|
||||
loader.stopAuto();
|
||||
loader.setProgress(92, t('generatingImage', '이미지 생성 중...'));
|
||||
const now = new Date();
|
||||
const timestamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`;
|
||||
const fileNameStr = `uadeck_${deckName}_${timestamp}`;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.download = `${fileNameStr}.png`;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
await loader.complete(t('downloadComplete', '다운로드 완료'));
|
||||
} catch (e) {
|
||||
console.error('Capture Error:', e);
|
||||
loader.hide();
|
||||
alert(translations.saveError || '이미지 저장 실패');
|
||||
}
|
||||
}
|
||||
|
||||
function goBackToCardList() {
|
||||
window.history.back();
|
||||
if (window.history.length > 1) {
|
||||
window.history.back();
|
||||
return;
|
||||
}
|
||||
const lang =
|
||||
new URLSearchParams(window.location.search).get('lang') ||
|
||||
localStorage.getItem('lang') ||
|
||||
'ko';
|
||||
const name = new URLSearchParams(window.location.search).get('seriesName') || '';
|
||||
window.location.href = `/cardList.html?series=${seriesKey}&seriesName=${encodeURIComponent(name)}&lang=${lang}`;
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
const lang =
|
||||
new URLSearchParams(window.location.search).get('lang') ||
|
||||
localStorage.getItem('lang') ||
|
||||
'ko';
|
||||
window.location.href = lang && lang !== 'ko' ? `/?lang=${lang}` : '/';
|
||||
}
|
||||
</script>
|
||||
<script src="/script/auth-header.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
193
script/api.js
Normal file
193
script/api.js
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* UADECK API 클라이언트
|
||||
*/
|
||||
const UaDeckApi = (() => {
|
||||
const TOKEN_KEY = 'uadeck_token';
|
||||
const USER_KEY = 'uadeck_user';
|
||||
|
||||
/**
|
||||
* API 베이스 URL
|
||||
* @returns {string}
|
||||
*/
|
||||
function getBaseUrl() {
|
||||
if (window.UADECK_API_BASE) return window.UADECK_API_BASE;
|
||||
|
||||
const { hostname, port, protocol } = window.location;
|
||||
const isLocal = hostname === 'localhost' || hostname === '127.0.0.1';
|
||||
// Live Server 등 정적 전용 포트 → API 컨테이너/프로세스(3000)로 직접 연결
|
||||
if (isLocal && port && port !== '8080' && port !== '80' && port !== '443' && port !== '') {
|
||||
return `${protocol}//${hostname}:3000/api/v1`;
|
||||
}
|
||||
|
||||
return '/api/v1';
|
||||
}
|
||||
|
||||
/**
|
||||
* API 미디어 경로를 브라우저에서 로드 가능한 URL로 변환
|
||||
* (Live Server 등 API 호스트가 다른 경우 대비)
|
||||
* @param {string|null} apiPath
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function resolveMediaUrl(apiPath) {
|
||||
if (!apiPath) return null;
|
||||
if (apiPath.startsWith('http://') || apiPath.startsWith('https://')) return apiPath;
|
||||
|
||||
const base = getBaseUrl();
|
||||
if (base.startsWith('http')) {
|
||||
const origin = base.replace(/\/api\/v1\/?$/, '');
|
||||
return `${origin}${apiPath}`;
|
||||
}
|
||||
return apiPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP 오류 메시지
|
||||
* @param {Response} res
|
||||
* @param {object} data
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatError(res, data) {
|
||||
if (res.status === 405 || res.status === 404) {
|
||||
return 'API 서버에 연결되지 않았습니다. Docker 또는 npm run api:dev 로 API를 실행하세요.';
|
||||
}
|
||||
if (res.status === 0 || res.type === 'opaque') {
|
||||
return '네트워크 오류입니다. API 서버가 실행 중인지 확인하세요.';
|
||||
}
|
||||
return data.error || `요청 실패 (${res.status})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장된 토큰
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function getToken() {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시된 사용자
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function getCachedUser() {
|
||||
try {
|
||||
const raw = localStorage.getItem(USER_KEY);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 정보 저장
|
||||
* @param {string} token
|
||||
* @param {object} user
|
||||
*/
|
||||
function saveAuth(token, user) {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그아웃
|
||||
*/
|
||||
function logout() {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* API 요청
|
||||
* @param {string} path
|
||||
* @param {object} [options]
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
async function request(path, options = {}) {
|
||||
const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) };
|
||||
const token = getToken();
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`${getBaseUrl()}${path}`, { ...options, headers });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(formatError(res, data));
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
return {
|
||||
getToken,
|
||||
getCachedUser,
|
||||
saveAuth,
|
||||
logout,
|
||||
getBaseUrl,
|
||||
resolveMediaUrl,
|
||||
|
||||
/** @param {string} email @param {string} password */
|
||||
login: (email, password) =>
|
||||
request('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }),
|
||||
|
||||
/** @param {object} body */
|
||||
register: (body) =>
|
||||
request('/auth/register', { method: 'POST', body: JSON.stringify(body) }),
|
||||
|
||||
getMe: () => request('/auth/me'),
|
||||
|
||||
/** @param {object} body */
|
||||
updateProfile: (body) => request('/auth/me', { method: 'PUT', body: JSON.stringify(body) }),
|
||||
|
||||
/** @param {string} currentPassword @param {string} newPassword */
|
||||
changePassword: (currentPassword, newPassword) =>
|
||||
request('/auth/me/password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
}),
|
||||
|
||||
/** @param {string} shareCode */
|
||||
getDeck: (shareCode) => request(`/decks/${shareCode}`),
|
||||
|
||||
getMyDecks: () => request('/decks'),
|
||||
|
||||
/** @param {object} body */
|
||||
saveDeck: (body) => request('/decks', { method: 'POST', body: JSON.stringify(body) }),
|
||||
|
||||
/** @param {string} shareCode @param {object} body */
|
||||
updateDeck: (shareCode, body) =>
|
||||
request(`/decks/${shareCode}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
|
||||
/** @param {string} shareCode */
|
||||
deleteDeck: (shareCode) => request(`/decks/${shareCode}`, { method: 'DELETE' }),
|
||||
|
||||
getPresets: () => request('/decks/presets'),
|
||||
|
||||
/** 관리자: 회원 목록 */
|
||||
adminGetUsers: (q = '', page = 1) =>
|
||||
request(`/admin/users?q=${encodeURIComponent(q)}&page=${page}`),
|
||||
|
||||
/** @param {number} id */
|
||||
adminGetUser: (id) => request(`/admin/users/${id}`),
|
||||
|
||||
/** @param {number} id @param {object} body */
|
||||
adminUpdateUser: (id, body) =>
|
||||
request(`/admin/users/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
|
||||
/** @param {number} id @param {string} newPassword */
|
||||
adminResetPassword: (id, newPassword) =>
|
||||
request(`/admin/users/${id}/reset-password`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ newPassword }),
|
||||
}),
|
||||
|
||||
adminGetDecks: () => request('/admin/decks'),
|
||||
|
||||
/** @param {object} body */
|
||||
adminCreatePreset: (body) =>
|
||||
request('/admin/decks/preset', { method: 'POST', body: JSON.stringify(body) }),
|
||||
|
||||
/** @param {string} shareCode */
|
||||
adminDeleteDeck: (shareCode) =>
|
||||
request(`/admin/decks/${shareCode}`, { method: 'DELETE' }),
|
||||
};
|
||||
})();
|
||||
|
||||
window.UaDeckApi = UaDeckApi;
|
||||
207
script/auth-header.js
Normal file
207
script/auth-header.js
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* 헤더 프로필 드롭다운
|
||||
*/
|
||||
(function initAuthHeader() {
|
||||
const DEFAULT_AVATAR =
|
||||
'data:image/svg+xml,' +
|
||||
encodeURIComponent(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#94a3b8"><path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"/></svg>',
|
||||
);
|
||||
|
||||
let currentUser = null;
|
||||
let dropdownOpen = false;
|
||||
|
||||
/**
|
||||
* HTML 이스케이프
|
||||
* @param {string} str
|
||||
* @returns {string}
|
||||
*/
|
||||
function escapeHtml(str) {
|
||||
return String(str ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/**
|
||||
* 드롭다운 닫기
|
||||
*/
|
||||
function closeDropdown() {
|
||||
const dd = document.getElementById('auth-dropdown');
|
||||
if (dd) dd.classList.remove('open');
|
||||
dropdownOpen = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 아바타 이미지 src (캐시 무효화 포함)
|
||||
* @param {object|null} user
|
||||
* @returns {string}
|
||||
*/
|
||||
function resolveAvatarSrc(user) {
|
||||
const raw = user?.avatarUrl;
|
||||
if (!raw) return DEFAULT_AVATAR;
|
||||
let src =
|
||||
window.UaDeckApi?.resolveMediaUrl
|
||||
? window.UaDeckApi.resolveMediaUrl(raw) || raw
|
||||
: raw;
|
||||
if (user?.updatedAt && src && !src.startsWith('data:')) {
|
||||
src += (src.includes('?') ? '&' : '?') + 't=' + encodeURIComponent(user.updatedAt);
|
||||
}
|
||||
return src;
|
||||
}
|
||||
|
||||
/**
|
||||
* 아바타 HTML
|
||||
* @param {object|null} user
|
||||
* @returns {string}
|
||||
*/
|
||||
function avatarHtml(user) {
|
||||
const src = resolveAvatarSrc(user);
|
||||
return `<img src="${src}" alt="profile" onerror="this.src='${DEFAULT_AVATAR}'" />`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로필 버튼 HTML (아바타 + 사용자명)
|
||||
* @param {object|null} user
|
||||
* @returns {string}
|
||||
*/
|
||||
function profileBtnHtml(user) {
|
||||
const avatar = avatarHtml(user);
|
||||
if (!user?.username) {
|
||||
return `<span class="auth-avatar-wrap">${avatar}</span>`;
|
||||
}
|
||||
return `
|
||||
<span class="auth-avatar-wrap">${avatar}</span>
|
||||
<span class="auth-username">${escapeHtml(user.username)}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 드롭다운 메뉴 렌더
|
||||
*/
|
||||
function renderDropdown() {
|
||||
const dd = document.getElementById('auth-dropdown');
|
||||
if (!dd) return;
|
||||
|
||||
if (!currentUser) {
|
||||
dd.innerHTML = `
|
||||
<a href="/login.html" class="auth-dropdown-item">로그인</a>
|
||||
<a href="/signup.html" class="auth-dropdown-item">회원가입</a>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const adminLink =
|
||||
currentUser.role === 'admin'
|
||||
? `<a href="/admin.html" class="auth-dropdown-item">관리자</a>`
|
||||
: '';
|
||||
|
||||
dd.innerHTML = `
|
||||
<div class="auth-dropdown-header">
|
||||
<div class="auth-dropdown-name">${currentUser.username}</div>
|
||||
<div class="auth-dropdown-email">${currentUser.email || ''}</div>
|
||||
</div>
|
||||
<a href="/profile.html" class="auth-dropdown-item">내 프로필</a>
|
||||
<a href="/deckHistory.html" class="auth-dropdown-item">내 덱 관리</a>
|
||||
<a href="/preset.html" class="auth-dropdown-item">공개 덱</a>
|
||||
${adminLink}
|
||||
<div class="auth-dropdown-divider"></div>
|
||||
<button type="button" class="auth-dropdown-item danger" id="auth-logout-btn">로그아웃</button>
|
||||
`;
|
||||
|
||||
const logoutBtn = document.getElementById('auth-logout-btn');
|
||||
if (logoutBtn) {
|
||||
logoutBtn.addEventListener('click', () => {
|
||||
window.UaDeckApi.logout();
|
||||
currentUser = null;
|
||||
closeDropdown();
|
||||
location.href = '/';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 헤더 UI 갱신
|
||||
*/
|
||||
function renderHeader() {
|
||||
const btn = document.getElementById('auth-avatar-btn');
|
||||
if (btn) {
|
||||
btn.innerHTML = profileBtnHtml(currentUser);
|
||||
btn.classList.toggle('auth-profile-btn--named', Boolean(currentUser?.username));
|
||||
}
|
||||
renderDropdown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 슬롯에 헤더 마운트
|
||||
* @param {HTMLElement} slot
|
||||
*/
|
||||
function mount(slot) {
|
||||
slot.innerHTML = `
|
||||
<div class="auth-header-wrap">
|
||||
<button type="button" id="auth-avatar-btn" class="auth-avatar-btn" aria-label="계정 메뉴">
|
||||
${profileBtnHtml(currentUser)}
|
||||
</button>
|
||||
<div id="auth-dropdown" class="auth-dropdown"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('auth-avatar-btn').addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
dropdownOpen = !dropdownOpen;
|
||||
const dd = document.getElementById('auth-dropdown');
|
||||
if (dropdownOpen) {
|
||||
await refreshCurrentUser();
|
||||
renderHeader();
|
||||
dd.classList.add('open');
|
||||
} else {
|
||||
dd.classList.remove('open');
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', closeDropdown);
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버에서 최신 사용자 정보 동기화
|
||||
*/
|
||||
async function refreshCurrentUser() {
|
||||
if (!window.UaDeckApi?.getToken()) {
|
||||
currentUser = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { user } = await window.UaDeckApi.getMe();
|
||||
currentUser = user;
|
||||
window.UaDeckApi.saveAuth(window.UaDeckApi.getToken(), user);
|
||||
} catch {
|
||||
window.UaDeckApi.logout();
|
||||
currentUser = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 초기화
|
||||
*/
|
||||
async function init() {
|
||||
currentUser = window.UaDeckApi.getCachedUser();
|
||||
await refreshCurrentUser();
|
||||
|
||||
const slot = document.getElementById('auth-header-slot');
|
||||
if (slot) {
|
||||
mount(slot);
|
||||
renderHeader();
|
||||
} else {
|
||||
renderHeader();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
|
||||
window.UaDeckAuth = {
|
||||
getUser: () => currentUser,
|
||||
refresh: init,
|
||||
refreshCurrentUser,
|
||||
};
|
||||
})();
|
||||
55
script/deck-prefs.js
Normal file
55
script/deck-prefs.js
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 덱 저장 시 공개 여부 기본값·프롬프트 설정
|
||||
*/
|
||||
const UaDeckPrefs = (() => {
|
||||
const LS_DEFAULT_PUBLIC = 'uadeck_default_deck_public';
|
||||
const LS_SKIP_PROMPT = 'uadeck_skip_deck_public_prompt';
|
||||
|
||||
/**
|
||||
* @param {object|null|undefined} user
|
||||
* @returns {{ defaultPublic: boolean, skipPrompt: boolean }}
|
||||
*/
|
||||
function getPrefs(user) {
|
||||
if (user && (user.defaultDeckPublic !== undefined || user.skipDeckPublicPrompt !== undefined)) {
|
||||
return {
|
||||
defaultPublic: user.defaultDeckPublic !== false,
|
||||
skipPrompt: Boolean(user.skipDeckPublicPrompt),
|
||||
};
|
||||
}
|
||||
return {
|
||||
defaultPublic: localStorage.getItem(LS_DEFAULT_PUBLIC) !== '0',
|
||||
skipPrompt: localStorage.getItem(LS_SKIP_PROMPT) === '1',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 로컬 캐시 동기화
|
||||
* @param {{ defaultPublic: boolean, skipPrompt: boolean }} prefs
|
||||
*/
|
||||
function syncLocal(prefs) {
|
||||
localStorage.setItem(LS_DEFAULT_PUBLIC, prefs.defaultPublic ? '1' : '0');
|
||||
localStorage.setItem(LS_SKIP_PROMPT, prefs.skipPrompt ? '1' : '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ defaultPublic: boolean, skipPrompt: boolean }} prefs
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function savePrefs(prefs) {
|
||||
syncLocal(prefs);
|
||||
if (!window.UaDeckApi?.getToken()) return;
|
||||
try {
|
||||
const { user } = await window.UaDeckApi.updateProfile({
|
||||
defaultDeckPublic: prefs.defaultPublic,
|
||||
skipDeckPublicPrompt: prefs.skipPrompt,
|
||||
});
|
||||
window.UaDeckApi.saveAuth(window.UaDeckApi.getToken(), user);
|
||||
} catch (err) {
|
||||
console.warn('덱 저장 설정 동기화 실패:', err);
|
||||
}
|
||||
}
|
||||
|
||||
return { getPrefs, syncLocal, savePrefs };
|
||||
})();
|
||||
|
||||
window.UaDeckPrefs = UaDeckPrefs;
|
||||
35
script/image-utils.js
Normal file
35
script/image-utils.js
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 이미지 파일을 PNG data URL로 리사이즈
|
||||
* @param {File} file
|
||||
* @param {number} [maxSize=320]
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
function resizeImageToPng(file, maxSize = 320) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(new Error('이미지를 읽을 수 없습니다.'));
|
||||
reader.onload = () => {
|
||||
const img = new Image();
|
||||
img.onerror = () => reject(new Error('이미지 형식이 올바르지 않습니다.'));
|
||||
img.onload = () => {
|
||||
const scale = Math.min(maxSize / img.width, maxSize / img.height, 1);
|
||||
const width = Math.max(1, Math.round(img.width * scale));
|
||||
const height = Math.max(1, Math.round(img.height * scale));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
reject(new Error('이미지 처리에 실패했습니다.'));
|
||||
return;
|
||||
}
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
resolve(canvas.toDataURL('image/png'));
|
||||
};
|
||||
img.src = reader.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
window.UaImageUtils = { resizeImageToPng };
|
||||
@@ -66,22 +66,21 @@ function goToCardSeries() {
|
||||
}
|
||||
|
||||
function goToDeckHistory() {
|
||||
window.location.href = "deckHistory.html"; // 덱 기록 페이지로 이동
|
||||
window.location.href = "deckHistory.html";
|
||||
toggleMobileMenu();
|
||||
}
|
||||
|
||||
function goToChangeLanguage() {
|
||||
// 언어 변경 페이지로 이동 (페이지가 없으면 기능 구현 필요)
|
||||
toggleMobileMenu();
|
||||
}
|
||||
|
||||
function goToPreset() {
|
||||
window.location.href = "preset.html"; // 메타 덱 페이지로 이동
|
||||
window.location.href = "preset.html";
|
||||
toggleMobileMenu();
|
||||
}
|
||||
|
||||
function goToMyCardsDatabase() {
|
||||
window.location.href = "myDatabase.html"; // 내 카드 데이터베이스 페이지로 이동
|
||||
window.location.href = "deckHistory.html";
|
||||
toggleMobileMenu();
|
||||
}
|
||||
|
||||
|
||||
72
signup.html
Normal file
72
signup.html
Normal file
@@ -0,0 +1,72 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="stylesheet" href="./style/output.css" />
|
||||
<link rel="stylesheet" href="./style/auth-pages.css" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=Noto+Sans+KR:wght@400;700&display=swap" rel="stylesheet" />
|
||||
<title>회원가입 - UADECK</title>
|
||||
</head>
|
||||
<body class="auth-page">
|
||||
<nav class="auth-nav">
|
||||
<div class="auth-nav-inner">
|
||||
<a href="/" class="auth-brand">
|
||||
<img src="/images/assets/apple-touch-icon.png" alt="UA Logo" />
|
||||
<span class="auth-brand-text">UADECK <span>v4.0.0</span></span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="auth-main">
|
||||
<header class="auth-header">
|
||||
<h1>회원가입</h1>
|
||||
<p>UADECK 계정을 만들고<br />덱을 저장·공유해 보세요.</p>
|
||||
</header>
|
||||
|
||||
<form id="signup-form" class="auth-card">
|
||||
<div class="auth-field">
|
||||
<label class="auth-label" for="email">이메일</label>
|
||||
<input type="email" id="email" required class="auth-input" placeholder="you@example.com" autocomplete="email" />
|
||||
</div>
|
||||
<div class="auth-field">
|
||||
<label class="auth-label" for="username">사용자명</label>
|
||||
<input type="text" id="username" required minlength="2" class="auth-input" placeholder="deckmaster" autocomplete="username" />
|
||||
</div>
|
||||
<div class="auth-field">
|
||||
<label class="auth-label" for="password">비밀번호</label>
|
||||
<input type="password" id="password" required minlength="8" class="auth-input" placeholder="8자 이상" autocomplete="new-password" />
|
||||
</div>
|
||||
<p id="error-msg" class="auth-error hidden"></p>
|
||||
<button type="submit" class="auth-submit">가입하기</button>
|
||||
</form>
|
||||
|
||||
<p class="auth-footer">
|
||||
이미 계정이 있으신가요?
|
||||
<a href="/login.html">로그인</a>
|
||||
</p>
|
||||
</main>
|
||||
|
||||
<script src="/script/api.js"></script>
|
||||
<script>
|
||||
document.getElementById('signup-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const errEl = document.getElementById('error-msg');
|
||||
errEl.classList.add('hidden');
|
||||
try {
|
||||
const body = {
|
||||
email: document.getElementById('email').value,
|
||||
username: document.getElementById('username').value,
|
||||
password: document.getElementById('password').value,
|
||||
};
|
||||
const { token, user } = await UaDeckApi.register(body);
|
||||
UaDeckApi.saveAuth(token, user);
|
||||
location.href = '/deckHistory.html';
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message;
|
||||
errEl.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
519
style/admin.css
Normal file
519
style/admin.css
Normal file
@@ -0,0 +1,519 @@
|
||||
/**
|
||||
* 관리자 페이지 (admin.html)
|
||||
*/
|
||||
|
||||
.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 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 800;
|
||||
color: #1e293b;
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.admin-brand span {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
max-width: 80rem;
|
||||
margin: 0 auto;
|
||||
padding: 3rem 1.5rem 5rem;
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
132
style/app-shell.css
Normal file
132
style/app-shell.css
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 앱 공통 셸 (헤더 1280px / max-w-7xl, 광고 레일)
|
||||
* index.html, cardList.html 과 동일한 콘텐츠 최대 너비 기준
|
||||
*/
|
||||
|
||||
:root {
|
||||
--app-max-width: 80rem; /* 1280px — Tailwind max-w-7xl */
|
||||
--app-gutter: 1.5rem;
|
||||
--app-nav-height: 4rem;
|
||||
--app-ad-rail-width: 18.75rem; /* 300px — 디스플레이 광고 */
|
||||
}
|
||||
|
||||
.app-page {
|
||||
font-family: 'Inter', 'Noto Sans KR', sans-serif;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.app-nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.app-nav-inner {
|
||||
max-width: var(--app-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--app-gutter);
|
||||
height: var(--app-nav-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.app-brand {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 800;
|
||||
color: #1e293b;
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.025em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-brand span {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.app-ad-top {
|
||||
max-width: var(--app-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0.75rem var(--app-gutter) 0;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
max-width: var(--app-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 2rem var(--app-gutter) 5rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.app-shell {
|
||||
padding-top: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.app-body-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-ad-rail {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-ad-slot {
|
||||
overflow: hidden;
|
||||
border-radius: 0.75rem;
|
||||
background: #f8fafc;
|
||||
border: 1px dashed #e2e8f0;
|
||||
}
|
||||
|
||||
.app-ad-slot--leaderboard {
|
||||
min-height: 90px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-ad-slot--skyscraper {
|
||||
min-height: 600px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-ad-slot ins {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.app-ad-top {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-body-layout {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.app-ad-rail {
|
||||
display: block;
|
||||
flex: 0 0 var(--app-ad-rail-width);
|
||||
width: var(--app-ad-rail-width);
|
||||
position: sticky;
|
||||
top: calc(var(--app-nav-height) + 1rem);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.app-ad-rail {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
134
style/auth-header.css
Normal file
134
style/auth-header.css
Normal file
@@ -0,0 +1,134 @@
|
||||
.auth-header-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.auth-avatar-btn {
|
||||
height: 36px;
|
||||
border-radius: 9999px;
|
||||
border: 2px solid #e2e8f0;
|
||||
background: #f1f5f9;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.auth-avatar-btn.auth-profile-btn--named {
|
||||
padding-right: 12px;
|
||||
gap: 8px;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.auth-avatar-btn:hover {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.auth-avatar-wrap {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin: 2px;
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.auth-avatar-wrap img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.auth-username {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.auth-avatar-btn:not(.auth-profile-btn--named) {
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
.auth-avatar-btn:not(.auth-profile-btn--named) .auth-avatar-wrap {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-dropdown {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 8px);
|
||||
min-width: 200px;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.12);
|
||||
padding: 6px;
|
||||
z-index: 200;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.auth-dropdown.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.auth-dropdown-header {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.auth-dropdown-name {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.auth-dropdown-email {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.auth-dropdown-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.auth-dropdown-item:hover {
|
||||
background: #f8fafc;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.auth-dropdown-item.danger:hover {
|
||||
color: #ef4444;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.auth-dropdown-divider {
|
||||
height: 1px;
|
||||
background: #f1f5f9;
|
||||
margin: 4px 0;
|
||||
}
|
||||
187
style/auth-pages.css
Normal file
187
style/auth-pages.css
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* 인증·계정 페이지 공통 레이아웃
|
||||
* (Tailwind 빌드에 없는 max-w-* 유틸 대체)
|
||||
*/
|
||||
|
||||
.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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-decoration: none;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.auth-brand img {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.auth-brand-text {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.auth-brand-text span {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
318
style/deck-history.css
Normal file
318
style/deck-history.css
Normal file
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* 내 덱 관리·공개 덱 페이지 (deckHistory.html, preset.html)
|
||||
*/
|
||||
|
||||
.deck-history-page {
|
||||
/* 배경·폰트는 .app-page */
|
||||
}
|
||||
|
||||
.deck-history-main {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.deck-history-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.deck-history-header h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 900;
|
||||
color: #0f172a;
|
||||
margin: 0 0 0.5rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.deck-history-header p {
|
||||
font-size: 0.875rem;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.deck-history-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem 1.25rem;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.deck-history-toolbar.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.deck-search-input {
|
||||
flex: 1 1 12rem;
|
||||
min-width: 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.625rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: #0f172a;
|
||||
background: #f8fafc;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.deck-search-input:focus {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.deck-search-input::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.deck-series-select {
|
||||
flex: 0 1 11rem;
|
||||
min-width: 9rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.625rem 2rem 0.625rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: #0f172a;
|
||||
background: #f8fafc url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2364748b' d='M3 4.5L6 7.5L9 4.5'/%3E%3C/svg%3E") no-repeat right 0.75rem center;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.deck-series-select:focus {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.deck-count-label {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.deck-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.deck-grid-empty {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.deck-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
|
||||
transition: box-shadow 0.2s, transform 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.deck-card:hover {
|
||||
border-color: #cbd5e1;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.deck-card-preview {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%);
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.deck-card-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.deck-card-preview-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.deck-card-preview-placeholder span {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 500;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.deck-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
padding: 1rem 1.125rem 1.125rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.deck-card-title {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.deck-card-meta {
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.deck-card-series {
|
||||
display: inline-block;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
color: #2563eb;
|
||||
background: #eff6ff;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.deck-card-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: auto;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.deck-card-btn {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.375rem;
|
||||
border: none;
|
||||
border-radius: 0.625rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, transform 0.1s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.deck-card-btn:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.deck-card-btn-load {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.deck-card-btn-load:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.deck-card-btn-copy {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.deck-card-btn-copy:hover {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.deck-card-btn-delete {
|
||||
flex: 0 0 auto;
|
||||
padding-left: 0.625rem;
|
||||
padding-right: 0.625rem;
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.deck-card-btn-delete:hover {
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.deck-card-visibility {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
margin-bottom: 0.125rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.deck-card-badge-public {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
color: #059669;
|
||||
background: #ecfdf5;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.deck-card-badge-private {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
background: #f1f5f9;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.deck-card-btn-toggle {
|
||||
flex: 1;
|
||||
background: #f0fdf4;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.deck-card-btn-toggle.is-public {
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.deck-card-btn-toggle:hover {
|
||||
background: #dcfce7;
|
||||
}
|
||||
|
||||
.deck-card-btn-toggle.is-public:hover {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.deck-status-message {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.deck-status-message.error {
|
||||
color: #ef4444;
|
||||
}
|
||||
305
style/profile.css
Normal file
305
style/profile.css
Normal file
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* 내 프로필 페이지 (profile.html)
|
||||
*/
|
||||
|
||||
.profile-page {
|
||||
/* 배경·폰트는 .app-page */
|
||||
}
|
||||
|
||||
.profile-content {
|
||||
width: 100%;
|
||||
max-width: 42rem;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.profile-header h1 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.profile-header p {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 1.25rem;
|
||||
box-shadow: 0 4px 24px rgba(15, 23, 42, 0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.profile-hero {
|
||||
padding: 1.75rem 1.5rem 1.25rem;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #eff6ff 100%);
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.profile-avatar-wrap {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 5.5rem;
|
||||
height: 5.5rem;
|
||||
border-radius: 9999px;
|
||||
object-fit: cover;
|
||||
background: #fff;
|
||||
border: 3px solid #fff;
|
||||
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.profile-avatar-btn {
|
||||
position: absolute;
|
||||
right: -0.125rem;
|
||||
bottom: -0.125rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 9999px;
|
||||
border: 2px solid #fff;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.profile-hero-text h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.profile-hero-text p {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.profile-avatar-remove {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.profile-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.profile-field {
|
||||
margin-bottom: 1.125rem;
|
||||
}
|
||||
|
||||
.profile-label {
|
||||
display: block;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.profile-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.profile-input:focus {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.profile-divider {
|
||||
height: 1px;
|
||||
background: #e2e8f0;
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
|
||||
.profile-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.875rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.profile-btn-primary {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.profile-btn-primary:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.profile-btn-primary:disabled {
|
||||
background: #cbd5e1;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.profile-section-title {
|
||||
margin: 0 0 0.375rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.profile-section-desc {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.8125rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.profile-toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.profile-toggle-row + .profile-toggle-row {
|
||||
border-top: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.profile-toggle-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.profile-toggle-hint {
|
||||
font-size: 0.6875rem;
|
||||
color: #94a3b8;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.profile-toggle {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
width: 2.75rem;
|
||||
height: 1.5rem;
|
||||
align-items: center;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
background: #e2e8f0;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.profile-toggle.is-on {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.profile-toggle-circle {
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 9999px;
|
||||
background: #fff;
|
||||
transform: translateX(0.25rem);
|
||||
transition: transform 0.2s;
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.15);
|
||||
}
|
||||
|
||||
.profile-toggle-circle.is-on {
|
||||
transform: translateX(1.5rem);
|
||||
}
|
||||
|
||||
.profile-btn-outline {
|
||||
background: #fff;
|
||||
color: #475569;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.profile-btn-outline:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.profile-error {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.profile-error.hidden,
|
||||
.profile-success.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.profile-success {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.profile-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);
|
||||
}
|
||||
|
||||
.profile-modal.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.profile-modal-box {
|
||||
width: 100%;
|
||||
max-width: 24rem;
|
||||
background: #fff;
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.15);
|
||||
}
|
||||
|
||||
.profile-modal-box h3 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.profile-modal-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.profile-modal-actions .profile-btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
Reference in New Issue
Block a user