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:
2026-06-22 16:59:36 +09:00
parent f8c954f24b
commit dbf02241e2
56 changed files with 6732 additions and 183 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
# Docker 빌드 시 API 이미지에서 제외
api/node_modules
api/data
.git
.env
*.md
docs/
.cursor/
.cursorrules

22
.env.example Normal file
View 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
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function resolveAvatarSrc(userOrUrl) {
const avatarUrl = typeof userOrUrl === 'object' ? userOrUrl?.avatarUrl : userOrUrl;
const updatedAt = typeof userOrUrl === 'object' ? userOrUrl?.updatedAt : null;
if (!avatarUrl) return DEFAULT_AVATAR;
let url = UaDeckApi.resolveMediaUrl(avatarUrl) || DEFAULT_AVATAR;
if (updatedAt && url && !url.startsWith('data:')) {
url += (url.includes('?') ? '&' : '?') + 't=' + encodeURIComponent(updatedAt);
}
return url;
}
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, '&#39;')}' 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
View File

@@ -0,0 +1,3 @@
node_modules/
data/
.env

18
api/Dockerfile Normal file
View 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
View 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
View 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
View 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)'}`);
});

View 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
View 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
View 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
View 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;
}

View 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);
}
}

View 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 };
}

View 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
View 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();
}

View File

@@ -6,7 +6,9 @@
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-4516420168710424" crossorigin="anonymous"></script> <script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-4516420168710424" crossorigin="anonymous"></script>
<title>Card List</title> <title>Card List</title>
<script src="/script/navigation.js"></script> <script src="/script/navigation.js"></script>
<script src="/script/api.js"></script>
<link rel="stylesheet" href="./style/output.css" /> <link rel="stylesheet" href="./style/output.css" />
<link rel="stylesheet" href="./style/auth-header.css" />
<script type="module" src="/i18n/i18n.js"></script> <script type="module" src="/i18n/i18n.js"></script>
<link href="/style/navigation.css" rel="stylesheet" /> <link href="/style/navigation.css" rel="stylesheet" />
<link href="/style/style.css" rel="stylesheet" /> <link href="/style/style.css" rel="stylesheet" />
@@ -81,12 +83,13 @@
</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"> <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"> <div class="font-bold text-slate-800">
<span id="i18n-searchResults">검색결과</span> <span id="i18n-searchResults">검색결과</span>
: :
<span id="SearchResultsCounter" class="text-blue-600">0</span> <span id="SearchResultsCounter" class="text-blue-600">0</span>
</div> </div>
<div id="auth-header-slot"></div>
</div> </div>
<div class="max-w-[1400px] mx-auto p-4 flex flex-col lg:flex-row gap-6"> <div class="max-w-[1400px] mx-auto p-4 flex flex-col lg:flex-row gap-6">
@@ -240,6 +243,8 @@
let cardList = []; let cardList = [];
let selectedCards = []; let selectedCards = [];
let selectedColors = new Set(['All']); let selectedColors = new Set(['All']);
let currentShareCode = null;
let currentDeckName = '';
const getEl = () => ({ const getEl = () => ({
searchQuery: document.getElementById('searchQuery'), searchQuery: document.getElementById('searchQuery'),
@@ -253,37 +258,63 @@
specialCount: document.getElementById('specialCardCount'), specialCount: document.getElementById('specialCardCount'),
}); });
const seriesKey = new URLSearchParams(window.location.search).get('series'); let seriesKey = new URLSearchParams(window.location.search).get('series');
const nameKey = new URLSearchParams(window.location.search).get('seriesName'); let nameKey = new URLSearchParams(window.location.search).get('seriesName');
let t = {}; let t = {};
async function init() { async function init() {
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const lang = urlParams.get('lang') || localStorage.getItem('lang') || 'ko'; const lang = urlParams.get('lang') || localStorage.getItem('lang') || 'ko';
const shareCode = urlParams.get('d');
if (!seriesKey) return;
const el = getEl(); const el = getEl();
if (el.loadingScreen) el.loadingScreen.style.display = 'flex'; if (el.loadingScreen) el.loadingScreen.style.display = 'flex';
try { try {
// 1. 다국어 데이터 로드
const tResponse = await fetch(`./i18n/translations.${lang}.json`); const tResponse = await fetch(`./i18n/translations.${lang}.json`);
t = await tResponse.json(); t = await tResponse.json();
applyI18n(); 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`); const module = await import(`./datas/${seriesKey}.js`);
cardList = module.cardList; cardList = module.cardList;
initSeriesFilter(); initSeriesFilter();
const cardsParam = new URLSearchParams(window.location.search).get('cards'); if (deckCards) {
const savedData = cardsParam ? decodeURIComponent(cardsParam) : localStorage.getItem('selectedCards'); cardList = syncCardData(cardList, deckCards);
selectedCards = deckCards;
if (savedData) { } else {
const parsed = JSON.parse(decodeURIComponent(savedData)); const savedData = localStorage.getItem('selectedCards');
cardList = syncCardData(cardList, parsed); if (savedData) {
selectedCards = parsed; const parsed = JSON.parse(decodeURIComponent(savedData));
cardList = syncCardData(cardList, parsed);
selectedCards = parsed;
}
} }
applyInitialFilter(); 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) { window.handleCount = function (imgSrc, delta, event) {
event.stopPropagation(); event.stopPropagation();
const card = cardList.find((c) => c.imgSrc === imgSrc); const card = cardList.find((c) => c.imgSrc === imgSrc);
@@ -605,24 +644,23 @@
} }
function syncCardData(base, saved) { 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) => { saved.forEach((s) => {
const item = map.get(s.imgSrc); const item = byImg.get(s.imgSrc) || byNum.get(s.number || s.Number);
if (item) { if (item) {
item.count = s.count; item.count = s.count;
item.key = s.key; item.key = Boolean(s.key);
} }
}); });
return Array.from(map.values()); return base;
} }
function saveAndRefresh() { function saveAndRefresh() {
selectedCards = cardList.filter((c) => c.count > 0); selectedCards = cardList.filter((c) => c.count > 0);
const encoded = encodeURIComponent(JSON.stringify(selectedCards)); const encoded = encodeURIComponent(JSON.stringify(selectedCards));
localStorage.setItem('selectedCards', encoded); localStorage.setItem('selectedCards', encoded);
const url = new URL(window.location.href); localStorage.setItem('selectedSeries', seriesKey);
url.searchParams.set('cards', encoded);
window.history.replaceState({}, '', url);
updateCounters(); updateCounters();
updateCardList(); updateCardList();
} }
@@ -713,8 +751,11 @@
// 4. 저장소 및 URL 동기화 // 4. 저장소 및 URL 동기화
localStorage.removeItem('selectedCards'); localStorage.removeItem('selectedCards');
localStorage.removeItem('selectedSeries');
currentShareCode = null;
currentDeckName = '';
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.searchParams.delete('cards'); url.searchParams.delete('d');
window.history.replaceState({}, '', url); window.history.replaceState({}, '', url);
// 5. 전체 갱신 // 5. 전체 갱신
@@ -750,19 +791,21 @@
document.querySelectorAll('input[name="triggerType"]').forEach((r) => { document.querySelectorAll('input[name="triggerType"]').forEach((r) => {
r.addEventListener('change', updateCardList); r.addEventListener('change', updateCardList);
}); });
document.getElementById('goToResultPage').addEventListener('click', () => { document.getElementById('goToResultPage').addEventListener('click', function () {
if (this.disabled) return; // 비활성화 상태면 무시 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) { selectedCards = currentSeriesSelected;
alert(translations.noCardsSelected || "선택된 카드가 없습니다."); const encoded = encodeURIComponent(JSON.stringify(selectedCards));
return; localStorage.setItem('selectedCards', encoded);
} localStorage.setItem('selectedSeries', seriesKey);
const lang = new URLSearchParams(window.location.search).get('lang') || localStorage.getItem('lang') || 'ko';
const queryParams = encodeURIComponent(JSON.stringify(selectedCards)); window.location.href = `/result.html?series=${seriesKey}&seriesName=${encodeURIComponent(nameKey || '')}&lang=${lang}`;
window.location.href = `/result.html?series=${seriesKey}&seriesName=${nameKey}&cards=${queryParams}`;
}); });
const resetModal = document.getElementById('resetModal'); const resetModal = document.getElementById('resetModal');
@@ -822,16 +865,12 @@
} }
} }
// 3. 로컬 스토리지 저장 (현재 시리즈 데이터만 깨끗하게 유지) // 3. 로컬 스토리지 저장
const encoded = encodeURIComponent(JSON.stringify(selectedCards)); const encoded = encodeURIComponent(JSON.stringify(selectedCards));
localStorage.setItem('selectedCards', encoded); localStorage.setItem('selectedCards', encoded);
localStorage.setItem('selectedSeries', seriesKey);
// 4. URL 업데이트 (새로고침 시 데이터 유지용) updateCounters();
const url = new URL(window.location.href);
url.searchParams.set('cards', encoded);
window.history.replaceState({}, '', url);
updateCounters(); // 하단 바 숫자만 업데이트
} }
let selectedSeriesGroups = new Set(['All']); let selectedSeriesGroups = new Set(['All']);
@@ -889,5 +928,6 @@
}); });
} }
</script> </script>
<script src="/script/auth-header.js"></script>
</body> </body>
</html> </html>

315
deckHistory.html Normal file
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/**
* 덱 표시 이름
* @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
View 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
View 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
View 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일) |

View File

@@ -5,12 +5,12 @@
- 특히 이 저장소는 정적 HTML/JS 프로젝트이면서 카드 데이터와 이미지 자산 비중이 크기 때문에, "코드 수정"보다 "데이터-번역-이미지 일치"가 더 중요하다. - 특히 이 저장소는 정적 HTML/JS 프로젝트이면서 카드 데이터와 이미지 자산 비중이 크기 때문에, "코드 수정"보다 "데이터-번역-이미지 일치"가 더 중요하다.
## 현재 기준 릴리즈 ## 현재 기준 릴리즈
- 현재 작업 기준 버전: `v3.1.0` - 현재 작업 기준 버전: `v4.0.0`
- 이번 릴리즈 핵심 반영: - 이번 릴리즈 핵심 반영:
- `ua51` 시리즈 추가 - 회원·인증·덱 API (`api/`, Docker)
- `ua49` 누락 병렬 이미지 보강 - 공유 코드 기반 덱 공유 (`/d/:code`, `GET /api/v1/decks/:shareCode`)
- 다국어 시리즈명 추가 - 관리자 화면 (`admin.html`)
- 버전 표기 및 문서 체계 정리 - `?cards=` URL 방식 폐기
## 다음 작업자에게 반드시 전달할 것 ## 다음 작업자에게 반드시 전달할 것
- 이 프로젝트는 빌드 시스템 중심 앱이 아니라 `index.html`, `cardList.html`, `result.html` 3개 페이지가 직접 동작하는 정적 사이트다. - 이 프로젝트는 빌드 시스템 중심 앱이 아니라 `index.html`, `cardList.html`, `result.html` 3개 페이지가 직접 동작하는 정적 사이트다.

View File

@@ -24,6 +24,7 @@
## CSS 규칙 ## CSS 규칙
- 공통 UI는 가능한 한 Tailwind 유틸리티 클래스를 우선 사용한다. - 공통 UI는 가능한 한 Tailwind 유틸리티 클래스를 우선 사용한다.
- `style/output.css`에 포함되지 않은 유틸리티 클래스는 적용되지 않으므로, 페이지 특화 스타일은 인라인 `<style>` 또는 전용 CSS 파일(`style/auth-pages.css` 등)에 명시한다.
- 페이지 특화 보정 스타일만 인라인 `<style>` 또는 전용 CSS 파일에 둔다. - 페이지 특화 보정 스타일만 인라인 `<style>` 또는 전용 CSS 파일에 둔다.
- 캡처 안정성처럼 이유가 분명한 예외 스타일은 주석으로 목적을 남긴다. - 캡처 안정성처럼 이유가 분명한 예외 스타일은 주석으로 목적을 남긴다.

192
docs/deploy.md Normal file
View 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` |

View File

@@ -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 릴리즈 기준 확정 ## 2026-04-19 / v3.1.0 릴리즈 기준 확정
- 배경: 저장소의 마지막 명시 릴리즈 기록은 `v3.0.1`인데, 메인 화면은 이미 `v3.1`을 노출하고 있어 실제 릴리즈 이력과 화면 표기가 어긋나 있었다. - 배경: 저장소의 마지막 명시 릴리즈 기록은 `v3.0.1`인데, 메인 화면은 이미 `v3.1`을 노출하고 있어 실제 릴리즈 이력과 화면 표기가 어긋나 있었다.
- 결정: - 결정:

View File

@@ -3,48 +3,51 @@
## `/` ## `/`
- 진입 파일: `index.html` - 진입 파일: `index.html`
- 화면 역할: 시리즈 선택 홈 화면 - 화면 역할: 시리즈 선택 홈 화면
- 사용자 행동: - 연결: `script/auth-header.js`, `script/api.js`
- 언어 선택
- 시리즈 카드 클릭
- 연결 파일:
- `i18n/translations.<lang>.json`: 제목, 설명, 시리즈명
- `script/navigation.js`: 공통 네비게이션 오버레이
- `style/output.css`: 공통 스타일
## `/cardList.html?series=<seriesKey>` ## `/cardList.html?series=<key>&d=<shareCode>`
- 진입 파일: `cardList.html` - 진입 파일: `cardList.html`
- 화면 역할: 카드 검색, 필터링, 수량 선택, 키 카드 지정 - 화면 역할: 카드 검색·필터·덱 편집·저장·공유
- 사용자 행동: - `?d=` 있으면 API에서 덱 복원
- 검색어 입력 - 연결: `datas/<seriesKey>.js`, `script/api.js`
- 색상/타입/트리거 필터
- 카드 수량 조절
- 키 카드 지정
- 덱 결과 페이지 이동
- 연결 파일:
- `datas/<seriesKey>.js`: 현재 시리즈 카드 원본 데이터
- `images/<seriesKey>/`: 카드 이미지 원본
- `i18n/translations.<lang>.json`: 버튼/문구 번역
- `script/navigation.js`: 공통 네비게이션
## `/result.html` ## `/result.html`
- 진입 파일: `result.html` - 진입 파일: `result.html`
- 화면 역할: 선택한 덱 미리보기 및 이미지 저장 - 화면 역할: 덱 미리보기·PNG 저장·내 덱 저장
- 사용자 행동: - 데이터 소스: `localStorage.selectedCards`
- 덱 이름 입력 - 하단 고정 바: `홈으로` | `이미지로 다운로드` | `내 덱으로 저장` (공개 여부 확인 모달)
- 출력 스타일 선택 - `script/deck-prefs.js`: 덱 공개 기본값·프롬프트 설정
- 고화질 저장 토글
- PNG 다운로드
- 연결 파일:
- `localStorage.selectedCards` 또는 `cards` 쿼리스트링: 덱 데이터 소스
- `images/<seriesKey>/`: 결과 이미지 렌더링 대상
- `i18n/translations.<lang>.json`: 결과 화면 번역
## 공통 보조 파일 ## `/d/<shareCode>` (Nginx 리다이렉트)
- `script/navigation.js`: 오버레이 메뉴 생성 및 이동 처리 - `nginx/uadeck.conf` 규칙으로 `cardList.html?d=` 로 이동
- `i18n/i18n.js`: `data-i18n` 기반 공통 번역 적용 - 옆 프로젝트 연동: `GET /api/v1/decks/<shareCode>`
- `style/style.css`, `style/navigation.css`, `style/output.css`: 공통/페이지 스타일
## 초보자용 이해 포인트 ## `/login.html`, `/signup.html`
- 이 프로젝트는 프레임워크 앱이 아니라 HTML 파일 3개가 직접 화면 역할을 나눠 갖는 구조다. - 인증 화면
- 카드 정보는 `datas/`에, 실제 카드 이미지는 `images/`에 따로 있다.
- 화면에서 보이는 한 장의 카드는 "데이터 파일의 객체 1개 + 이미지 폴더의 파일 1개" 조합으로 만들어진다. ## `/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`

View File

@@ -1,9 +1,10 @@
# 기술 명세 # 기술 명세
## 1. 프로젝트 개요 ## 1. 프로젝트 개요
- 프로젝트명: `UADECK V3.1.0 - Union Arena Deck Builder` - 프로젝트명: `UADECK V4.0.0 - Union Arena Deck Builder`
- 형태: 정적 HTML/CSS/JavaScript 기반 덱 빌더 - 형태: 정적 HTML/CSS/JavaScript 프론트 + Docker API 백엔드
- 패키지 의존성: `tailwindcss`, `@tailwindcss/cli` - 패키지 의존성 (프론트): `tailwindcss`, `@tailwindcss/cli`
- 패키지 의존성 (API): `express`, `better-sqlite3`, `bcryptjs`, `jsonwebtoken`, `cors`
- 빌드 산출 스타일: `style/output.css` - 빌드 산출 스타일: `style/output.css`
## 2. 화면 구조 ## 2. 화면 구조
@@ -85,8 +86,10 @@
## 6. 상태 저장 ## 6. 상태 저장
- `localStorage.lang`: 현재 언어 - `localStorage.lang`: 현재 언어
- `localStorage.selectedCards`: 선택 카드 목록(JSON 문자열을 `encodeURIComponent` 처리) - `localStorage.selectedCards`: 편집 중인 덱 (임시, `encodeURIComponent` JSON)
- `localStorage.selectedSeries`: 일부 흐름에서 사용하는 선택 시리즈 값 - `localStorage.selectedSeries`: 현재 시리즈 키
- `localStorage.uadeck_token` / `uadeck_user`: 로그인 세션
- 덱 공유·영구 저장: API + DB (`GET /api/v1/decks/:shareCode`)
## 7. 스타일 및 자산 ## 7. 스타일 및 자산
- Tailwind 유틸리티 클래스와 페이지별 인라인 스타일을 혼용한다. - Tailwind 유틸리티 클래스와 페이지별 인라인 스타일을 혼용한다.
@@ -99,3 +102,157 @@
- `cardList.html`과 `result.html`의 스크립트가 페이지 내부에 크게 포함되어 있어 모듈 분리 전까지는 변경 영향 범위가 넓다. - `cardList.html`과 `result.html`의 스크립트가 페이지 내부에 크게 포함되어 있어 모듈 분리 전까지는 변경 영향 범위가 넓다.
- `script/navigation.js`의 일부 이동 대상 페이지는 현재 저장소에 존재하지 않는다. - `script/navigation.js`의 일부 이동 대상 페이지는 현재 저장소에 존재하지 않는다.
- 이미지가 Git 일반 객체로 계속 누적되면 저장소 히스토리 크기가 지속 증가한다. - 이미지가 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/` 라우팅

View File

@@ -7,12 +7,36 @@
- `cardList.html`과 `result.html` 내부 스크립트 비중이 커서 기능 확장 시 유지보수 난도가 올라갈 수 있다. - `cardList.html`과 `result.html` 내부 스크립트 비중이 커서 기능 확장 시 유지보수 난도가 올라갈 수 있다.
- 데이터 파일 네이밍과 샘플 파일(`datas/ua99 sample.js`, `datas/ua99 sample mini.js`) 처리 기준이 문서상 명확하지 않았다. - 데이터 파일 네이밍과 샘플 파일(`datas/ua99 sample.js`, `datas/ua99 sample mini.js`) 처리 기준이 문서상 명확하지 않았다.
## 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` 업로드가 정상 완료되었는지 확인한다.
- 협업자가 있다면 히스토리 재작성 이후 동기화 방법을 공지한다. - 협업자가 있다면 히스토리 재작성 이후 동기화 방법을 공지한다.
- `Git LFS` 도입 후에도 저장소 운영 비용이 크면 이미지 저장소 분리/CDN 이전을 재검토한다. - `Git LFS` 도입 후에도 저장소 운영 비용이 크면 이미지 저장소 분리/CDN 이전을 재검토한다.
- `cardList.html`의 인라인 스크립트를 별도 JS 모듈로 분리하는 리팩터링을 검토한다. - `cardList.html`의 인라인 스크립트를 별도 JS 모듈로 분리하는 리팩터링을 검토한다.
- 존재하지 않는 페이지 링크를 정리하거나, 향후 구현 예정이면 문서에 상태를 명확히 표기한다.
## 판단 가이드 ## 판단 가이드
- 신규 이미지 추가 관리가 목적이면 `Git LFS` 추적만으로도 충분하다. - 신규 이미지 추가 관리가 목적이면 `Git LFS` 추적만으로도 충분하다.

View File

@@ -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 ## 2026-04-19 / v3.1.0
### 수행 작업 ### 수행 작업

View File

@@ -20,7 +20,7 @@
"editDeck": "← EDIT DECK", "editDeck": "← EDIT DECK",
"deckPreview": "Deck Preview", "deckPreview": "Deck Preview",
"deckNameLabel": "Deck Name", "deckNameLabel": "Deck Name",
"deckNamePlaceholder": "Series name used if empty", "deckNamePlaceholder": "YYYYMMDD_HHMMSS if empty",
"exportStyle": "Export Style", "exportStyle": "Export Style",
"type1Title": "Type 1: Signature", "type1Title": "Type 1: Signature",
"type1Desc": "Standard style highlighting Key Cards", "type1Desc": "Standard style highlighting Key Cards",
@@ -31,13 +31,21 @@
"exportNotice1": "* If image fails to generate, please check 'Pop-up' settings.", "exportNotice1": "* If image fails to generate, please check 'Pop-up' settings.",
"exportNotice2": "* On iOS, you may need to long-press the image to save.", "exportNotice2": "* On iOS, you may need to long-press the image to save.",
"generatingImage": "Generating Image...", "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", "downloadImage": "DOWNLOAD IMAGE",
"saveToMyDeck": "Save to My Decks",
"shareDeck": "Share",
"keyCardLabel": "KEY CARD", "keyCardLabel": "KEY CARD",
"back": "BACK", "back": "BACK",
"saveError": "Failed to save image", "saveError": "Failed to save image",
"all": "All", "all": "All",
"metaDeck": "Meta Deck", "metaDeck": "Public Decks",
"titlePrompt": "Please select a title", "titlePrompt": "Please select a title",
"selectTitle": "Select Title", "selectTitle": "Select Title",
"deckName": "Deck Name", "deckName": "Deck Name",

View File

@@ -20,7 +20,7 @@
"editDeck": "← デッキを編集", "editDeck": "← デッキを編集",
"deckPreview": "デッキプレビュー", "deckPreview": "デッキプレビュー",
"deckNameLabel": "デッキ名", "deckNameLabel": "デッキ名",
"deckNamePlaceholder": "未入力時はシリーズ名を使用", "deckNamePlaceholder": "未入力時は YYYYMMDD_HHMMSS",
"exportStyle": "書き出しスタイル", "exportStyle": "書き出しスタイル",
"type1Title": "タイプ 1: Signature", "type1Title": "タイプ 1: Signature",
"type1Desc": "キーカードを強調する基本スタイル", "type1Desc": "キーカードを強調する基本スタイル",
@@ -31,13 +31,21 @@
"exportNotice1": "* 画像が生成されない場合は「ポップアップ許可」を確認してください。", "exportNotice1": "* 画像が生成されない場合は「ポップアップ許可」を確認してください。",
"exportNotice2": "* iOS環境では画像を長押しして保存する必要がある場合があります。", "exportNotice2": "* iOS環境では画像を長押しして保存する必要がある場合があります。",
"generatingImage": "画像生成中...", "generatingImage": "画像生成中...",
"savingPreview": "プレビュー生成中...",
"savingToMyDeck": "マイデックに保存中...",
"sharingDeck": "共有リンク作成中...",
"saveComplete": "保存完了!",
"shareComplete": "共有準備完了!",
"downloadComplete": "ダウンロード完了!",
"downloadImage": "画像を保存する", "downloadImage": "画像を保存する",
"saveToMyDeck": "マイデックに保存",
"shareDeck": "共有",
"keyCardLabel": "KEY CARD", "keyCardLabel": "KEY CARD",
"back": "戻る", "back": "戻る",
"saveError": "画像の保存に失敗しました", "saveError": "画像の保存に失敗しました",
"all": "全て", "all": "全て",
"metaDeck": "メタデッキ", "metaDeck": "公開デッキ",
"titlePrompt": "タイトルを選択してください", "titlePrompt": "タイトルを選択してください",
"selectTitle": "タイトルを選択", "selectTitle": "タイトルを選択",
"deckName": "デッキ名", "deckName": "デッキ名",

View File

@@ -20,7 +20,7 @@
"editDeck": "← EDIT DECK", "editDeck": "← EDIT DECK",
"deckPreview": "Deck Preview", "deckPreview": "Deck Preview",
"deckNameLabel": "Deck Name", "deckNameLabel": "Deck Name",
"deckNamePlaceholder": "미입력 시 시리즈명 사용", "deckNamePlaceholder": "미입력 시 YYYYMMDD_HHMMSS",
"exportStyle": "Export Style", "exportStyle": "Export Style",
"type1Title": "Type 1: Signature", "type1Title": "Type 1: Signature",
"type1Desc": "키 카드를 강조하는 기본 스타일", "type1Desc": "키 카드를 강조하는 기본 스타일",
@@ -31,13 +31,21 @@
"exportNotice1": "* 이미지가 생성되지 않을 경우 브라우저의 '팝업 허용'을 확인해주세요.", "exportNotice1": "* 이미지가 생성되지 않을 경우 브라우저의 '팝업 허용'을 확인해주세요.",
"exportNotice2": "* iOS 환경에서는 이미지를 길게 눌러 저장해야 할 수도 있습니다.", "exportNotice2": "* iOS 환경에서는 이미지를 길게 눌러 저장해야 할 수도 있습니다.",
"generatingImage": "이미지 생성 중...", "generatingImage": "이미지 생성 중...",
"savingPreview": "미리보기 생성 중...",
"savingToMyDeck": "내 덱에 저장 중...",
"sharingDeck": "공유 링크 생성 중...",
"saveComplete": "저장 완료!",
"shareComplete": "공유 준비 완료!",
"downloadComplete": "다운로드 완료!",
"downloadImage": "DOWNLOAD IMAGE", "downloadImage": "DOWNLOAD IMAGE",
"saveToMyDeck": "내 덱에 저장",
"shareDeck": "공유",
"keyCardLabel": "KEY CARD", "keyCardLabel": "KEY CARD",
"back": "BACK", "back": "BACK",
"saveError": "이미지 저장 실패", "saveError": "이미지 저장 실패",
"all": "전체", "all": "전체",
"metaDeck": "메타 덱", "metaDeck": "공개 덱",
"titlePrompt": "작품을 선택해주세요", "titlePrompt": "작품을 선택해주세요",
"selectTitle": "작품 선택", "selectTitle": "작품 선택",
"deckName": "덱 이름", "deckName": "덱 이름",

View File

@@ -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" /> <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> <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> <style>
body { body {
@@ -62,14 +64,17 @@
</div> </div>
<span class="text-xl font-extrabold tracking-tight text-slate-800"> <span class="text-xl font-extrabold tracking-tight text-slate-800">
UADECK UADECK
<span class="text-blue-600">v3.1.0</span> <span class="text-blue-600">v4.0.0</span>
</span> </span>
</div> </div>
<div class="language-selector flex gap-1 p-1 bg-slate-100 rounded-xl"> <div class="flex items-center gap-4">
<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> <div class="language-selector flex gap-1 p-1 bg-slate-100 rounded-xl">
<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('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('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> <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>
</div> </div>
</nav> </nav>
@@ -169,5 +174,6 @@
window.addEventListener('popstate', () => loadSeriesList()); window.addEventListener('popstate', () => loadSeriesList());
loadSeriesList(); loadSeriesList();
</script> </script>
<script src="/script/auth-header.js"></script>
</body> </body>
</html> </html>

28
integrations/README.md Normal file
View 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
View 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>

View 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
View 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
View 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
View 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
View 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;
}

View File

@@ -2,5 +2,13 @@
"dependencies": { "dependencies": {
"@tailwindcss/cli": "^4.1.12", "@tailwindcss/cli": "^4.1.12",
"tailwindcss": "^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
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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
View 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>

View File

@@ -5,6 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <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> <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/output.css" />
<link rel="stylesheet" href="./style/auth-header.css" />
<script src="/script/api.js"></script>
<title>Deck Result - UA Deck Builder</title> <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> <script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script>
<style> <style>
@@ -127,21 +129,226 @@
gap: 20px !important; gap: 20px !important;
width: 100% !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> </style>
</head> </head>
<body> <body>
<div id="app" class="min-h-screen pb-24"> <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 id="loading-screen" class="save-loading-overlay">
<div class="flex flex-col items-center gap-3"> <div class="save-loading-box">
<div class="animate-spin rounded-full h-12 w-12 border-4 border-blue-500 border-t-transparent"></div> <p id="loading-message" class="save-loading-message"></p>
<p id="i18n-loadingMsg" class="text-sm font-bold text-slate-600">이미지 생성 중...</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> </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"> <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> <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="i18n-previewTitle" class="font-bold text-slate-800 uppercase tracking-wider">Deck Preview</div>
<div id="auth-header-slot"></div>
</div> </div>
<div class="max-w-[1400px] mx-auto p-4 flex flex-col lg:flex-row gap-6"> <div class="max-w-[1400px] mx-auto p-4 flex flex-col lg:flex-row gap-6">
@@ -193,14 +400,47 @@
</main> </main>
</div> </div>
<div class="fixed bottom-0 left-0 right-0 p-4 bg-white/80 backdrop-blur-md border-t z-50"> <div class="result-footer-bar">
<div class="max-w-7xl mx-auto flex gap-3"> <div class="result-footer-inner">
<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 type="button" id="btn-go-home" onclick="goHome()" class="result-btn result-btn-home">홈으로</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> <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>
</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"> <script type="module">
let currentStyle = 'type1'; let currentStyle = 'type1';
let selectedCards = []; let selectedCards = [];
@@ -209,8 +449,10 @@
// 전역 함수로 등록 (onclick 이벤트 대응) // 전역 함수로 등록 (onclick 이벤트 대응)
window.changeStyle = changeStyle; window.changeStyle = changeStyle;
window.saveAsImage = saveAsImage; window.saveAsImage = saveAsImage;
window.saveToMyDeck = saveToMyDeck;
window.toggleHQ = toggleHQ; window.toggleHQ = toggleHQ;
window.goBackToCardList = goBackToCardList; window.goBackToCardList = goBackToCardList;
window.goHome = goHome;
let translations = {}; let translations = {};
@@ -227,18 +469,20 @@
console.error('i18n 로드 실패:', e); console.error('i18n 로드 실패:', e);
} }
// 2. 카드 데이터 로드 및 렌더링 // 2. 카드 데이터 로드 및 렌더링
let cardsParam = urlParams.get('cards') || localStorage.getItem('selectedCards'); let cardsParam = localStorage.getItem('selectedCards');
seriesKey = urlParams.get('series') || localStorage.getItem('selectedSeries'); seriesKey = urlParams.get('series') || localStorage.getItem('selectedSeries');
if (cardsParam && seriesKey) { if (cardsParam && seriesKey) {
try { try {
// 데이터 로드 시 decodeURIComponent 적용
selectedCards = JSON.parse(decodeURIComponent(cardsParam)); selectedCards = JSON.parse(decodeURIComponent(cardsParam));
renderResult(); renderResult();
} catch (e) { } catch (e) {
console.error('Data parsing error:', 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() { function applyTranslationsToUI() {
@@ -261,9 +505,9 @@
setEl('i18n-hqDesc', 'hqDesc'); setEl('i18n-hqDesc', 'hqDesc');
setEl('i18n-notice1', 'exportNotice1'); setEl('i18n-notice1', 'exportNotice1');
setEl('i18n-notice2', 'exportNotice2'); setEl('i18n-notice2', 'exportNotice2');
setEl('i18n-btnBack', 'back');
setEl('i18n-btnDownload', 'downloadImage'); 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'); const deckInput = document.getElementById('deckName');
if (deckInput) deckInput.placeholder = translations.deckNamePlaceholder || ""; if (deckInput) deckInput.placeholder = translations.deckNamePlaceholder || "";
@@ -333,7 +577,6 @@
<div class="mt-2 text-[10px] font-mono text-slate-400">${card.Number}</div> <div class="mt-2 text-[10px] font-mono text-slate-400">${card.Number}</div>
`; `;
return div; return div;
return div;
} }
function changeStyle(style) { function changeStyle(style) {
@@ -361,18 +604,74 @@
circle.style.transform = cb.checked ? 'translateX(20px)' : 'translateX(0px)'; circle.style.transform = cb.checked ? 'translateX(20px)' : 'translateX(0px)';
} }
async function saveAsImage() { function getSeriesName() {
const screen = document.getElementById('loading-screen'); const raw = new URLSearchParams(window.location.search).get('seriesName') || '';
screen.style.display = 'flex'; 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 area = document.getElementById('resultArea');
const hq = document.getElementById('highQuality').checked; 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 originalStyle = area.getAttribute('style') || '';
const gridElements = area.querySelectorAll('div[class*="grid"]'); const gridElements = area.querySelectorAll('div[class*="grid"]');
const originalGrids = Array.from(gridElements).map((el) => el.className); const originalGrids = Array.from(gridElements).map((el) => el.className);
@@ -393,80 +692,325 @@
allCardWrappers.forEach((wrapper) => { allCardWrappers.forEach((wrapper) => {
const box = wrapper.querySelector('.card-image-box'); const box = wrapper.querySelector('.card-image-box');
if (box) { if (box) {
// 기존에 입혀진 모든 ring 관련 클래스나 style 제거 box.style.boxShadow = 'none';
box.style.boxShadow = 'none'; box.style.outline = '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')) { await waitForImages(area);
box.classList.add('key-card-border'); // 우리가 만든 인디고 보더 적용 await new Promise((resolve) => setTimeout(resolve, 300));
} else { const scale = hq ? 2 : 1;
box.classList.add('capture-safe-border'); // 일반 연한 보더 적용 const originalDisplay = area.style.display;
} area.style.display = 'inline-block';
}
// Type 1 상단 키 카드 너비 고정 try {
if (wrapper.classList.contains('w-48') || wrapper.classList.contains('lg:w-56')) { return await htmlToImage.toPng(area, {
wrapper.style.width = '240px'; pixelRatio: scale,
} backgroundColor: '#ffffff',
}); cacheBust: true,
canvasWidth: (area.offsetWidth + 80) * scale,
try { canvasHeight: (area.offsetHeight + 80) * scale,
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,
}); });
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 { } finally {
// 5. 스타일 복구 area.style.display = originalDisplay;
area.setAttribute('style', originalStyle); area.setAttribute('style', originalStyle);
gridElements.forEach((el, i) => { gridElements.forEach((el, i) => {
el.className = originalGrids[i]; el.className = originalGrids[i];
}); });
allCardWrappers.forEach((wrapper) => { allCardWrappers.forEach((wrapper) => {
wrapper.style.width = ''; 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() { 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>
<script src="/script/auth-header.js"></script>
</body> </body>
</html> </html>

193
script/api.js Normal file
View 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
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/**
* 드롭다운 닫기
*/
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
View 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
View 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 };

View File

@@ -66,22 +66,21 @@ function goToCardSeries() {
} }
function goToDeckHistory() { function goToDeckHistory() {
window.location.href = "deckHistory.html"; // 덱 기록 페이지로 이동 window.location.href = "deckHistory.html";
toggleMobileMenu(); toggleMobileMenu();
} }
function goToChangeLanguage() { function goToChangeLanguage() {
// 언어 변경 페이지로 이동 (페이지가 없으면 기능 구현 필요)
toggleMobileMenu(); toggleMobileMenu();
} }
function goToPreset() { function goToPreset() {
window.location.href = "preset.html"; // 메타 덱 페이지로 이동 window.location.href = "preset.html";
toggleMobileMenu(); toggleMobileMenu();
} }
function goToMyCardsDatabase() { function goToMyCardsDatabase() {
window.location.href = "myDatabase.html"; // 내 카드 데이터베이스 페이지로 이동 window.location.href = "deckHistory.html";
toggleMobileMenu(); toggleMobileMenu();
} }

72
signup.html Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}