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

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>