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:
315
deckHistory.html
Normal file
315
deckHistory.html
Normal file
@@ -0,0 +1,315 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-4516420168710424" crossorigin="anonymous"></script>
|
||||
<link rel="stylesheet" href="./style/output.css" />
|
||||
<link rel="stylesheet" href="./style/app-shell.css" />
|
||||
<link rel="stylesheet" href="./style/auth-header.css" />
|
||||
<link rel="stylesheet" href="./style/deck-history.css" />
|
||||
<title>내 덱 관리 - UADECK</title>
|
||||
</head>
|
||||
<body class="app-page deck-history-page">
|
||||
<nav class="app-nav">
|
||||
<div class="app-nav-inner">
|
||||
<a href="/" class="app-brand">UADECK <span>v4.0.0</span></a>
|
||||
<div id="auth-header-slot"></div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="app-ad-top">
|
||||
<div class="app-ad-slot app-ad-slot--leaderboard" id="ad-leaderboard" aria-label="광고"></div>
|
||||
</div>
|
||||
|
||||
<div class="app-shell">
|
||||
<div class="app-body-layout">
|
||||
<main class="app-main deck-history-main">
|
||||
<header class="deck-history-header">
|
||||
<h1>내 덱 관리</h1>
|
||||
<p>저장한 덱 목록입니다. 공개 전환 시「공개 덱」목록에 표시됩니다.</p>
|
||||
</header>
|
||||
|
||||
<div id="deck-toolbar" class="deck-history-toolbar hidden">
|
||||
<input
|
||||
type="search"
|
||||
id="deck-search"
|
||||
class="deck-search-input"
|
||||
placeholder="덱 이름 또는 시리즈 검색..."
|
||||
autocomplete="off"
|
||||
/>
|
||||
<select id="deck-series-filter" class="deck-series-select" aria-label="시리즈 필터">
|
||||
<option value="">모든 시리즈</option>
|
||||
</select>
|
||||
<span id="deck-count" class="deck-count-label"></span>
|
||||
</div>
|
||||
|
||||
<div id="deck-list">
|
||||
<div class="deck-status-message">불러오는 중...</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside class="app-ad-rail" aria-label="광고">
|
||||
<div class="app-ad-slot app-ad-slot--skyscraper" id="ad-sidebar"></div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/script/api.js"></script>
|
||||
<script src="/script/auth-header.js"></script>
|
||||
<script>
|
||||
/** @type {Array<object>} */
|
||||
let allDecks = [];
|
||||
|
||||
/**
|
||||
* HTML 이스케이프
|
||||
* @param {string} str
|
||||
* @returns {string}
|
||||
*/
|
||||
function escapeHtml(str) {
|
||||
return String(str ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/**
|
||||
* 덱 표시 이름
|
||||
* @param {object} deck
|
||||
* @returns {string}
|
||||
*/
|
||||
function getDeckTitle(deck) {
|
||||
return deck.name || deck.series_name || deck.series || '이름 없음';
|
||||
}
|
||||
|
||||
/**
|
||||
* 시리즈 필터 옵션 갱신
|
||||
* @param {Array<object>} decks
|
||||
*/
|
||||
function populateSeriesFilter(decks) {
|
||||
const select = document.getElementById('deck-series-filter');
|
||||
const current = select.value;
|
||||
const seriesMap = new Map();
|
||||
|
||||
decks.forEach((d) => {
|
||||
if (!seriesMap.has(d.series)) {
|
||||
seriesMap.set(d.series, d.series_name || d.series);
|
||||
}
|
||||
});
|
||||
|
||||
const sorted = [...seriesMap.entries()].sort((a, b) =>
|
||||
String(a[1]).localeCompare(String(b[1]), 'ko'),
|
||||
);
|
||||
|
||||
select.innerHTML =
|
||||
'<option value="">모든 시리즈</option>' +
|
||||
sorted
|
||||
.map(
|
||||
([key, label]) =>
|
||||
`<option value="${escapeHtml(key)}">${escapeHtml(label)}</option>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
if (current && seriesMap.has(current)) {
|
||||
select.value = current;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색·필터 적용
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
function getFilteredDecks() {
|
||||
const query = document.getElementById('deck-search').value.trim().toLowerCase();
|
||||
const series = document.getElementById('deck-series-filter').value;
|
||||
|
||||
return allDecks.filter((d) => {
|
||||
if (series && d.series !== series) return false;
|
||||
if (!query) return true;
|
||||
|
||||
const haystack = [
|
||||
getDeckTitle(d),
|
||||
d.series_name,
|
||||
d.series,
|
||||
d.share_code,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 미리보기 HTML
|
||||
* @param {object} d
|
||||
* @param {string} title
|
||||
* @returns {string}
|
||||
*/
|
||||
function renderPreviewHtml(d, title) {
|
||||
if (!d.preview_url) {
|
||||
return '<div class="deck-card-preview-placeholder">미리보기 없음<br><span>Preview에서 다시 저장</span></div>';
|
||||
}
|
||||
const src = UaDeckApi.resolveMediaUrl(d.preview_url);
|
||||
const version = d.updated_at ? `?v=${encodeURIComponent(d.updated_at)}` : '';
|
||||
return `<img src="${escapeHtml(src + version)}" alt="${title} 미리보기" loading="lazy" class="deck-card-preview-img" />`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 덱 카드 HTML
|
||||
* @param {object} d
|
||||
* @returns {string}
|
||||
*/
|
||||
function renderDeckCard(d) {
|
||||
const title = escapeHtml(getDeckTitle(d));
|
||||
const seriesLabel = escapeHtml(d.series_name || d.series);
|
||||
const date = new Date(d.updated_at).toLocaleDateString('ko-KR');
|
||||
const preview = renderPreviewHtml(d, title);
|
||||
const isPublic = Boolean(d.is_public);
|
||||
const visibilityBadge = isPublic
|
||||
? '<span class="deck-card-badge-public">공개</span>'
|
||||
: '<span class="deck-card-badge-private">비공개</span>';
|
||||
const toggleLabel = isPublic ? '비공개 전환' : '공개 전환';
|
||||
const toggleClass = isPublic ? 'deck-card-btn-toggle is-public' : 'deck-card-btn-toggle';
|
||||
|
||||
return `
|
||||
<article class="deck-card" data-code="${escapeHtml(d.share_code)}">
|
||||
<div class="deck-card-preview">${preview}</div>
|
||||
<div class="deck-card-body">
|
||||
<div class="deck-card-visibility">
|
||||
<span class="deck-card-series">${seriesLabel}</span>
|
||||
${visibilityBadge}
|
||||
</div>
|
||||
<h2 class="deck-card-title">${title}</h2>
|
||||
<div class="deck-card-meta">조회 ${d.view_count} · ${date}</div>
|
||||
<div class="deck-card-actions">
|
||||
<button type="button" class="deck-card-btn deck-card-btn-load" data-load="${escapeHtml(d.share_code)}" data-series="${escapeHtml(d.series)}" data-name="${escapeHtml(d.series_name || '')}">불러오기</button>
|
||||
<button type="button" class="deck-card-btn ${toggleClass}" data-toggle="${escapeHtml(d.share_code)}" data-public="${isPublic ? '1' : '0'}">${toggleLabel}</button>
|
||||
</div>
|
||||
<div class="deck-card-actions">
|
||||
<button type="button" class="deck-card-btn deck-card-btn-copy" data-copy="${escapeHtml(d.share_code)}">링크 복사</button>
|
||||
<button type="button" class="deck-card-btn deck-card-btn-delete" data-delete="${escapeHtml(d.share_code)}" title="삭제" aria-label="삭제">삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 목록 렌더링
|
||||
*/
|
||||
function renderDeckList() {
|
||||
const list = document.getElementById('deck-list');
|
||||
const countEl = document.getElementById('deck-count');
|
||||
const filtered = getFilteredDecks();
|
||||
|
||||
countEl.textContent = `${filtered.length} / ${allDecks.length}개`;
|
||||
|
||||
if (!filtered.length) {
|
||||
list.innerHTML =
|
||||
'<div class="deck-grid"><div class="deck-grid-empty">조건에 맞는 덱이 없습니다.</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = `<div class="deck-grid">${filtered.map(renderDeckCard).join('')}</div>`;
|
||||
|
||||
list.querySelectorAll('.deck-card-preview-img').forEach((img) => {
|
||||
img.addEventListener('error', () => {
|
||||
const wrap = img.closest('.deck-card-preview');
|
||||
if (wrap) {
|
||||
wrap.innerHTML =
|
||||
'<div class="deck-card-preview-placeholder">미리보기 없음<br><span>Preview에서 다시 저장</span></div>';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 덱 목록 API 로드
|
||||
*/
|
||||
async function loadDecks() {
|
||||
const list = document.getElementById('deck-list');
|
||||
const toolbar = document.getElementById('deck-toolbar');
|
||||
|
||||
if (!UaDeckApi.getToken()) {
|
||||
location.href = '/login.html?redirect=' + encodeURIComponent(location.pathname);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { decks } = await UaDeckApi.getMyDecks();
|
||||
allDecks = decks;
|
||||
|
||||
if (!decks.length) {
|
||||
toolbar.classList.add('hidden');
|
||||
list.innerHTML =
|
||||
'<div class="deck-status-message">저장된 덱이 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
toolbar.classList.remove('hidden');
|
||||
populateSeriesFilter(decks);
|
||||
renderDeckList();
|
||||
} catch (err) {
|
||||
toolbar.classList.add('hidden');
|
||||
list.innerHTML = `<div class="deck-status-message error">${escapeHtml(err.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadDecks();
|
||||
|
||||
document.getElementById('deck-search').addEventListener('input', renderDeckList);
|
||||
document.getElementById('deck-series-filter').addEventListener('change', renderDeckList);
|
||||
|
||||
document.getElementById('deck-list').addEventListener('click', async (e) => {
|
||||
const loadBtn = e.target.closest('[data-load]');
|
||||
if (loadBtn) {
|
||||
const code = loadBtn.dataset.load;
|
||||
const series = loadBtn.dataset.series;
|
||||
const name = loadBtn.dataset.name;
|
||||
location.href = `/cardList.html?series=${series}&seriesName=${encodeURIComponent(name)}&d=${code}`;
|
||||
return;
|
||||
}
|
||||
|
||||
const copyBtn = e.target.closest('[data-copy]');
|
||||
if (copyBtn) {
|
||||
const url = `${location.origin}/d/${copyBtn.dataset.copy}`;
|
||||
await navigator.clipboard.writeText(url);
|
||||
alert('공유 링크가 복사되었습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const toggleBtn = e.target.closest('[data-toggle]');
|
||||
if (toggleBtn) {
|
||||
const code = toggleBtn.dataset.toggle;
|
||||
const nextPublic = toggleBtn.dataset.public !== '1';
|
||||
const label = nextPublic ? '이 덱을 공개 덱 목록에 표시합니다. 계속할까요?' : '비공개로 전환합니다. 공개 목록에서 숨겨집니다.';
|
||||
if (!confirm(label)) return;
|
||||
await UaDeckApi.updateDeck(code, { isPublic: nextPublic });
|
||||
const deck = allDecks.find((d) => d.share_code === code);
|
||||
if (deck) deck.is_public = nextPublic ? 1 : 0;
|
||||
renderDeckList();
|
||||
return;
|
||||
}
|
||||
|
||||
const deleteBtn = e.target.closest('[data-delete]');
|
||||
if (deleteBtn) {
|
||||
if (!confirm('이 덱을 삭제하시겠습니까?')) return;
|
||||
await UaDeckApi.deleteDeck(deleteBtn.dataset.delete);
|
||||
allDecks = allDecks.filter((d) => d.share_code !== deleteBtn.dataset.delete);
|
||||
if (!allDecks.length) {
|
||||
document.getElementById('deck-toolbar').classList.add('hidden');
|
||||
document.getElementById('deck-list').innerHTML =
|
||||
'<div class="deck-status-message">저장된 덱이 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
populateSeriesFilter(allDecks);
|
||||
renderDeckList();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user