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

View File

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