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:
704
result.html
704
result.html
@@ -5,6 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-4516420168710424" crossorigin="anonymous"></script>
|
||||
<link rel="stylesheet" href="./style/output.css" />
|
||||
<link rel="stylesheet" href="./style/auth-header.css" />
|
||||
<script src="/script/api.js"></script>
|
||||
<title>Deck Result - UA Deck Builder</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script>
|
||||
<style>
|
||||
@@ -127,21 +129,226 @@
|
||||
gap: 20px !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.result-footer-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(8px);
|
||||
border-top: 1px solid #e2e8f0;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.result-footer-inner {
|
||||
max-width: 80rem;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.result-btn-home {
|
||||
background: #64748b;
|
||||
}
|
||||
|
||||
.result-btn-home:hover {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
.result-btn {
|
||||
flex: 1;
|
||||
padding: 1rem 0.5rem;
|
||||
border: none;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, transform 0.1s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.result-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.result-btn-save {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.result-btn-save:hover {
|
||||
background: #047857;
|
||||
}
|
||||
|
||||
.result-btn-download {
|
||||
background: #2563eb;
|
||||
box-shadow: 0 10px 15px -3px rgba(37, 99, 235, 0.25);
|
||||
}
|
||||
|
||||
.result-btn-download:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.save-deck-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 110;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
}
|
||||
|
||||
.save-deck-modal.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.save-deck-modal-box {
|
||||
width: 100%;
|
||||
max-width: 22rem;
|
||||
background: #fff;
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.15);
|
||||
}
|
||||
|
||||
.save-deck-modal-box h3 {
|
||||
margin: 0 0 0.375rem;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.save-deck-modal-desc {
|
||||
margin: 0 0 1.25rem;
|
||||
font-size: 0.8125rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.save-deck-toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.save-deck-toggle-row + .save-deck-toggle-row {
|
||||
border-top: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.save-deck-toggle-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.save-deck-toggle-hint {
|
||||
font-size: 0.6875rem;
|
||||
color: #94a3b8;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.save-deck-modal-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.save-deck-modal-actions .result-btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.result-btn-modal-cancel {
|
||||
background: #fff;
|
||||
color: #475569;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.result-btn-modal-cancel:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.save-loading-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.save-loading-box {
|
||||
width: min(20rem, calc(100% - 2rem));
|
||||
padding: 1.75rem 1.5rem;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 1.25rem;
|
||||
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.save-loading-message {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
text-align: center;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.save-loading-bar-track {
|
||||
height: 0.5rem;
|
||||
background: #e2e8f0;
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.save-loading-bar-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: linear-gradient(90deg, #2563eb, #3b82f6);
|
||||
border-radius: 9999px;
|
||||
transition: width 0.35s ease;
|
||||
}
|
||||
|
||||
.save-loading-submessage {
|
||||
margin: 0.75rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
text-align: center;
|
||||
min-height: 1.125rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.result-footer-inner {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app" class="min-h-screen pb-24">
|
||||
<div id="loading-screen" class="fixed inset-0 z-[100] bg-white/80 backdrop-blur-sm flex items-center justify-center" style="display: none">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-4 border-blue-500 border-t-transparent"></div>
|
||||
<p id="i18n-loadingMsg" class="text-sm font-bold text-slate-600">이미지 생성 중...</p>
|
||||
<div id="loading-screen" class="save-loading-overlay">
|
||||
<div class="save-loading-box">
|
||||
<p id="loading-message" class="save-loading-message"></p>
|
||||
<div class="save-loading-bar-track">
|
||||
<div id="loading-bar-fill" class="save-loading-bar-fill"></div>
|
||||
</div>
|
||||
<p id="loading-submessage" class="save-loading-submessage"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sticky top-0 z-50 h-16 bg-white/80 backdrop-blur-md border-b px-4 py-3 flex justify-between items-center">
|
||||
<button id="i18n-editDeck" onclick="goBackToCardList()" class="font-bold text-slate-600 hover:text-blue-600 transition-colors">← EDIT DECK</button>
|
||||
<div id="i18n-previewTitle" class="font-bold text-slate-800 uppercase tracking-wider">Deck Preview</div>
|
||||
<div id="auth-header-slot"></div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-[1400px] mx-auto p-4 flex flex-col lg:flex-row gap-6">
|
||||
@@ -193,14 +400,47 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="fixed bottom-0 left-0 right-0 p-4 bg-white/80 backdrop-blur-md border-t z-50">
|
||||
<div class="max-w-7xl mx-auto flex gap-3">
|
||||
<button id="i18n-btnBack" onclick="goBackToCardList()" class="flex-1 bg-slate-100 hover:bg-slate-200 text-slate-600 py-4 rounded-2xl font-bold transition-all">BACK</button>
|
||||
<button id="i18n-btnDownload" onclick="saveAsImage()" class="flex-[2] bg-blue-600 hover:bg-blue-700 text-white py-4 rounded-2xl font-bold shadow-lg shadow-blue-200 transition-all active:scale-95">DOWNLOAD IMAGE</button>
|
||||
<div class="result-footer-bar">
|
||||
<div class="result-footer-inner">
|
||||
<button type="button" id="btn-go-home" onclick="goHome()" class="result-btn result-btn-home">홈으로</button>
|
||||
<button type="button" id="i18n-btnDownload" onclick="saveAsImage()" class="result-btn result-btn-download">이미지로 다운로드</button>
|
||||
<button type="button" id="btn-save-deck" onclick="saveToMyDeck()" class="result-btn result-btn-save">내 덱으로 저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="save-deck-modal" class="save-deck-modal">
|
||||
<div class="save-deck-modal-box">
|
||||
<h3>모두에게 공개할까요?</h3>
|
||||
<p class="save-deck-modal-desc">공개하면「공개 덱」목록에 표시됩니다.</p>
|
||||
<div class="save-deck-toggle-row">
|
||||
<div>
|
||||
<div class="save-deck-toggle-label">공개</div>
|
||||
<div class="save-deck-toggle-hint">기본값: 예</div>
|
||||
</div>
|
||||
<button type="button" id="save-public-toggle" class="relative inline-flex h-6 w-11 items-center rounded-full bg-blue-600 transition-colors">
|
||||
<span id="save-public-circle" class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform translate-x-6"></span>
|
||||
</button>
|
||||
<input type="checkbox" id="save-public-check" class="hidden" checked />
|
||||
</div>
|
||||
<div class="save-deck-toggle-row">
|
||||
<div>
|
||||
<div class="save-deck-toggle-label">다음부터 물어보지 않기</div>
|
||||
<div class="save-deck-toggle-hint">선택한 공개 여부가 기본값이 됩니다</div>
|
||||
</div>
|
||||
<button type="button" id="save-skip-toggle" class="relative inline-flex h-6 w-11 items-center rounded-full bg-slate-200 transition-colors">
|
||||
<span id="save-skip-circle" class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform translate-x-1"></span>
|
||||
</button>
|
||||
<input type="checkbox" id="save-skip-check" class="hidden" />
|
||||
</div>
|
||||
<div class="save-deck-modal-actions">
|
||||
<button type="button" id="save-deck-cancel" class="result-btn result-btn-modal-cancel">취소</button>
|
||||
<button type="button" id="save-deck-confirm" class="result-btn result-btn-save">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/script/deck-prefs.js"></script>
|
||||
<script type="module">
|
||||
let currentStyle = 'type1';
|
||||
let selectedCards = [];
|
||||
@@ -209,8 +449,10 @@
|
||||
// 전역 함수로 등록 (onclick 이벤트 대응)
|
||||
window.changeStyle = changeStyle;
|
||||
window.saveAsImage = saveAsImage;
|
||||
window.saveToMyDeck = saveToMyDeck;
|
||||
window.toggleHQ = toggleHQ;
|
||||
window.goBackToCardList = goBackToCardList;
|
||||
window.goHome = goHome;
|
||||
|
||||
let translations = {};
|
||||
|
||||
@@ -227,18 +469,20 @@
|
||||
console.error('i18n 로드 실패:', e);
|
||||
}
|
||||
// 2. 카드 데이터 로드 및 렌더링
|
||||
let cardsParam = urlParams.get('cards') || localStorage.getItem('selectedCards');
|
||||
let cardsParam = localStorage.getItem('selectedCards');
|
||||
seriesKey = urlParams.get('series') || localStorage.getItem('selectedSeries');
|
||||
|
||||
if (cardsParam && seriesKey) {
|
||||
try {
|
||||
// 데이터 로드 시 decodeURIComponent 적용
|
||||
selectedCards = JSON.parse(decodeURIComponent(cardsParam));
|
||||
renderResult();
|
||||
} catch (e) {
|
||||
console.error('Data parsing error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
bindToggle('save-public-toggle', 'save-public-circle', 'save-public-check');
|
||||
bindToggle('save-skip-toggle', 'save-skip-circle', 'save-skip-check');
|
||||
});
|
||||
|
||||
function applyTranslationsToUI() {
|
||||
@@ -261,9 +505,9 @@
|
||||
setEl('i18n-hqDesc', 'hqDesc');
|
||||
setEl('i18n-notice1', 'exportNotice1');
|
||||
setEl('i18n-notice2', 'exportNotice2');
|
||||
setEl('i18n-btnBack', 'back');
|
||||
setEl('i18n-btnDownload', 'downloadImage');
|
||||
setEl('i18n-loadingMsg', 'generatingImage');
|
||||
setEl('btn-save-deck', 'saveToMyDeck', false);
|
||||
setEl('btn-go-home', 'goHome', false);
|
||||
|
||||
const deckInput = document.getElementById('deckName');
|
||||
if (deckInput) deckInput.placeholder = translations.deckNamePlaceholder || "";
|
||||
@@ -333,7 +577,6 @@
|
||||
<div class="mt-2 text-[10px] font-mono text-slate-400">${card.Number}</div>
|
||||
`;
|
||||
return div;
|
||||
return div;
|
||||
}
|
||||
|
||||
function changeStyle(style) {
|
||||
@@ -361,18 +604,74 @@
|
||||
circle.style.transform = cb.checked ? 'translateX(20px)' : 'translateX(0px)';
|
||||
}
|
||||
|
||||
async function saveAsImage() {
|
||||
const screen = document.getElementById('loading-screen');
|
||||
screen.style.display = 'flex';
|
||||
function getSeriesName() {
|
||||
const raw = new URLSearchParams(window.location.search).get('seriesName') || '';
|
||||
try {
|
||||
return decodeURIComponent(raw) || seriesKey;
|
||||
} catch {
|
||||
return raw || seriesKey;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장용 덱 이름 (입력값 없으면 YYYYMMDD_HHMMSS)
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatDeckTimestamp(date = new Date()) {
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}_${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
function getDeckSaveName() {
|
||||
const input = document.getElementById('deckName').value.trim();
|
||||
if (input) return input;
|
||||
return formatDeckTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* API 저장용 카드 페이로드
|
||||
* @returns {object[]}
|
||||
*/
|
||||
function getCardsPayload() {
|
||||
return selectedCards
|
||||
.filter((c) => c.count > 0)
|
||||
.map((c) => ({
|
||||
number: c.Number,
|
||||
count: c.count,
|
||||
key: Boolean(c.key),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 미리보기 영역 내 이미지 로드 대기
|
||||
* @param {HTMLElement} container
|
||||
*/
|
||||
async function waitForImages(container) {
|
||||
const imgs = Array.from(container.querySelectorAll('img'));
|
||||
await Promise.all(
|
||||
imgs.map(
|
||||
(img) =>
|
||||
new Promise((resolve) => {
|
||||
if (img.complete && img.naturalWidth > 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const done = () => resolve();
|
||||
img.addEventListener('load', done, { once: true });
|
||||
img.addEventListener('error', done, { once: true });
|
||||
setTimeout(done, 8000);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 미리보기 영역 PNG 캡처
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function capturePreviewPng() {
|
||||
const area = document.getElementById('resultArea');
|
||||
const hq = document.getElementById('highQuality').checked;
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const seriesName = urlParams.get('seriesName') || '';
|
||||
|
||||
const deckNameInput = document.getElementById('deckName').value.trim();
|
||||
const deckName = deckNameInput || seriesName;
|
||||
|
||||
const originalStyle = area.getAttribute('style') || '';
|
||||
const gridElements = area.querySelectorAll('div[class*="grid"]');
|
||||
const originalGrids = Array.from(gridElements).map((el) => el.className);
|
||||
@@ -393,80 +692,325 @@
|
||||
allCardWrappers.forEach((wrapper) => {
|
||||
const box = wrapper.querySelector('.card-image-box');
|
||||
if (box) {
|
||||
// 기존에 입혀진 모든 ring 관련 클래스나 style 제거
|
||||
box.style.boxShadow = 'none';
|
||||
box.style.outline = 'none';
|
||||
box.style.boxShadow = 'none';
|
||||
box.style.outline = 'none';
|
||||
if (box.classList.contains('key-card-highlight')) {
|
||||
box.classList.add('key-card-border');
|
||||
} else {
|
||||
box.classList.add('capture-safe-border');
|
||||
}
|
||||
}
|
||||
if (wrapper.classList.contains('w-48') || wrapper.classList.contains('lg:w-56')) {
|
||||
wrapper.style.width = '240px';
|
||||
}
|
||||
});
|
||||
|
||||
if (box.classList.contains('key-card-highlight')) {
|
||||
box.classList.add('key-card-border'); // 우리가 만든 인디고 보더 적용
|
||||
} else {
|
||||
box.classList.add('capture-safe-border'); // 일반 연한 보더 적용
|
||||
}
|
||||
}
|
||||
await waitForImages(area);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
const scale = hq ? 2 : 1;
|
||||
const originalDisplay = area.style.display;
|
||||
area.style.display = 'inline-block';
|
||||
|
||||
// Type 1 상단 키 카드 너비 고정
|
||||
if (wrapper.classList.contains('w-48') || wrapper.classList.contains('lg:w-56')) {
|
||||
wrapper.style.width = '240px';
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
const scale = hq ? 2 : 1;
|
||||
|
||||
// 캡처 직전 임시로 너비와 여백을 고정하여 중앙 정렬 유도
|
||||
const originalDisplay = area.style.display;
|
||||
area.style.display = 'inline-block'; // 내용물만큼만 너비를 잡도록 설정
|
||||
|
||||
const dataUrl = await htmlToImage.toPng(area, {
|
||||
pixelRatio: scale,
|
||||
backgroundColor: '#ffffff',
|
||||
// 캔버스 자체에 여백을 명시적으로 추가
|
||||
canvasWidth: (area.offsetWidth + 80) * scale,
|
||||
canvasHeight: (area.offsetHeight + 80) * scale,
|
||||
try {
|
||||
return await htmlToImage.toPng(area, {
|
||||
pixelRatio: scale,
|
||||
backgroundColor: '#ffffff',
|
||||
cacheBust: true,
|
||||
canvasWidth: (area.offsetWidth + 80) * scale,
|
||||
canvasHeight: (area.offsetHeight + 80) * scale,
|
||||
});
|
||||
|
||||
area.style.display = originalDisplay;
|
||||
|
||||
const now = new Date();
|
||||
const YYYY = now.getFullYear();
|
||||
const MM = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const DD = String(now.getDate()).padStart(2, '0');
|
||||
const hh = String(now.getHours()).padStart(2, '0');
|
||||
const mm = String(now.getMinutes()).padStart(2, '0');
|
||||
const ss = String(now.getSeconds()).padStart(2, '0');
|
||||
|
||||
const timestamp = `${YYYY}${MM}${DD}_${hh}${mm}${ss}`;
|
||||
const fileNameStr = deckName.trim() ? `uadeck_${deckName}_${timestamp}` : `uadeck_${timestamp}`;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.download = `${fileNameStr}.png`;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
} catch (e) {
|
||||
console.error('Capture Error:', e);
|
||||
alert('이미지 저장 실패');
|
||||
} finally {
|
||||
// 5. 스타일 복구
|
||||
area.style.display = originalDisplay;
|
||||
area.setAttribute('style', originalStyle);
|
||||
gridElements.forEach((el, i) => {
|
||||
el.className = originalGrids[i];
|
||||
});
|
||||
allCardWrappers.forEach((wrapper) => {
|
||||
wrapper.style.width = '';
|
||||
const box = wrapper.querySelector('.card-image-box');
|
||||
if (box) {
|
||||
// box.classList.remove('key-card-border', 'capture-safe-border');
|
||||
}
|
||||
});
|
||||
screen.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장·공유 로딩 UI
|
||||
* @param {string} message
|
||||
* @returns {{ setProgress: Function, setSubmessage: Function, complete: Function, hide: Function }}
|
||||
*/
|
||||
function beginSaveLoading(message) {
|
||||
const screen = document.getElementById('loading-screen');
|
||||
const msgEl = document.getElementById('loading-message');
|
||||
const subEl = document.getElementById('loading-submessage');
|
||||
const fillEl = document.getElementById('loading-bar-fill');
|
||||
let autoTimer = null;
|
||||
let current = 0;
|
||||
|
||||
const stopAuto = () => {
|
||||
if (autoTimer) {
|
||||
clearInterval(autoTimer);
|
||||
autoTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const setProgress = (percent, nextMessage) => {
|
||||
current = Math.max(current, percent);
|
||||
fillEl.style.width = `${Math.min(100, current)}%`;
|
||||
if (nextMessage) msgEl.textContent = nextMessage;
|
||||
};
|
||||
|
||||
const startAuto = (cap) => {
|
||||
stopAuto();
|
||||
autoTimer = setInterval(() => {
|
||||
if (current >= cap) return;
|
||||
current += 1;
|
||||
fillEl.style.width = `${current}%`;
|
||||
}, 120);
|
||||
};
|
||||
|
||||
msgEl.textContent = message;
|
||||
subEl.textContent = '';
|
||||
fillEl.style.width = '0%';
|
||||
current = 0;
|
||||
screen.style.display = 'flex';
|
||||
|
||||
return {
|
||||
setProgress,
|
||||
setSubmessage: (text) => {
|
||||
subEl.textContent = text;
|
||||
},
|
||||
startAuto,
|
||||
stopAuto,
|
||||
complete: async (doneMessage) => {
|
||||
stopAuto();
|
||||
setProgress(100, doneMessage);
|
||||
await new Promise((r) => setTimeout(r, 450));
|
||||
screen.style.display = 'none';
|
||||
},
|
||||
hide: () => {
|
||||
stopAuto();
|
||||
screen.style.display = 'none';
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* i18n 헬퍼
|
||||
* @param {string} key
|
||||
* @param {string} fallback
|
||||
* @returns {string}
|
||||
*/
|
||||
function t(key, fallback) {
|
||||
return translations[key] || fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* 토글 UI 상태 설정
|
||||
* @param {string} toggleId
|
||||
* @param {string} circleId
|
||||
* @param {string} checkboxId
|
||||
* @param {boolean} on
|
||||
*/
|
||||
function setToggleState(toggleId, circleId, checkboxId, on) {
|
||||
const toggle = document.getElementById(toggleId);
|
||||
const circle = document.getElementById(circleId);
|
||||
const checkbox = document.getElementById(checkboxId);
|
||||
if (!toggle || !circle || !checkbox) return;
|
||||
checkbox.checked = on;
|
||||
toggle.classList.toggle('bg-blue-600', on);
|
||||
toggle.classList.toggle('bg-slate-200', !on);
|
||||
circle.classList.toggle('translate-x-6', on);
|
||||
circle.classList.toggle('translate-x-1', !on);
|
||||
}
|
||||
|
||||
/**
|
||||
* 토글 클릭 바인딩
|
||||
* @param {string} toggleId
|
||||
* @param {string} circleId
|
||||
* @param {string} checkboxId
|
||||
*/
|
||||
function bindToggle(toggleId, circleId, checkboxId) {
|
||||
const toggle = document.getElementById(toggleId);
|
||||
if (!toggle) return;
|
||||
toggle.addEventListener('click', () => {
|
||||
const checkbox = document.getElementById(checkboxId);
|
||||
setToggleState(toggleId, circleId, checkboxId, !checkbox.checked);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장 시 공개 여부 확인 (설정·모달)
|
||||
* @returns {Promise<boolean|null>} null이면 취소
|
||||
*/
|
||||
function resolveIsPublicForSave() {
|
||||
const user = window.UaDeckApi?.getCachedUser?.() || null;
|
||||
const prefs = window.UaDeckPrefs.getPrefs(user);
|
||||
if (prefs.skipPrompt) {
|
||||
return Promise.resolve(prefs.defaultPublic);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const modal = document.getElementById('save-deck-modal');
|
||||
setToggleState(
|
||||
'save-public-toggle',
|
||||
'save-public-circle',
|
||||
'save-public-check',
|
||||
true,
|
||||
);
|
||||
setToggleState(
|
||||
'save-skip-toggle',
|
||||
'save-skip-circle',
|
||||
'save-skip-check',
|
||||
false,
|
||||
);
|
||||
modal.classList.add('open');
|
||||
|
||||
const cleanup = () => {
|
||||
modal.classList.remove('open');
|
||||
confirmBtn.removeEventListener('click', onConfirm);
|
||||
cancelBtn.removeEventListener('click', onCancel);
|
||||
modal.removeEventListener('click', onBackdrop);
|
||||
};
|
||||
|
||||
const onConfirm = async () => {
|
||||
const isPublic = document.getElementById('save-public-check').checked;
|
||||
const skip = document.getElementById('save-skip-check').checked;
|
||||
cleanup();
|
||||
if (skip) {
|
||||
await window.UaDeckPrefs.savePrefs({
|
||||
defaultPublic: isPublic,
|
||||
skipPrompt: true,
|
||||
});
|
||||
}
|
||||
resolve(isPublic);
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
cleanup();
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
const onBackdrop = (e) => {
|
||||
if (e.target === modal) onCancel();
|
||||
};
|
||||
|
||||
const confirmBtn = document.getElementById('save-deck-confirm');
|
||||
const cancelBtn = document.getElementById('save-deck-cancel');
|
||||
confirmBtn.addEventListener('click', onConfirm);
|
||||
cancelBtn.addEventListener('click', onCancel);
|
||||
modal.addEventListener('click', onBackdrop);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 덱 API 저장 실행
|
||||
* @param {boolean} isPublic
|
||||
*/
|
||||
async function executeDeckSave(isPublic) {
|
||||
const cards = getCardsPayload();
|
||||
if (!cards.length) {
|
||||
alert('저장할 카드가 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const loader = beginSaveLoading(
|
||||
t('savingToMyDeck', '내 덱으로 저장 중...'),
|
||||
);
|
||||
try {
|
||||
loader.setProgress(8, t('savingPreview', '미리보기 생성 중...'));
|
||||
loader.startAuto(52);
|
||||
let previewImage = null;
|
||||
try {
|
||||
previewImage = await capturePreviewPng();
|
||||
} catch (captureErr) {
|
||||
console.warn('Preview capture failed:', captureErr);
|
||||
}
|
||||
loader.stopAuto();
|
||||
loader.setProgress(58, t('savingToMyDeck', '내 덱으로 저장 중...'));
|
||||
loader.startAuto(88);
|
||||
const deck = await UaDeckApi.saveDeck({
|
||||
series: seriesKey,
|
||||
seriesName: getSeriesName(),
|
||||
name: getDeckSaveName(),
|
||||
cards,
|
||||
previewImage,
|
||||
isPublic,
|
||||
});
|
||||
loader.stopAuto();
|
||||
const visibility = isPublic ? '공개' : '비공개';
|
||||
if (!previewImage) {
|
||||
await loader.complete(t('saveComplete', '저장 완료'));
|
||||
alert(
|
||||
`덱이 ${visibility}로 저장되었습니다.\n(미리보기 이미지 생성 실패)\n${deck.name}`,
|
||||
);
|
||||
} else {
|
||||
await loader.complete(t('saveComplete', '저장 완료'));
|
||||
alert(`덱이 ${visibility}로 저장되었습니다.\n${deck.name}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
loader.hide();
|
||||
alert(e.message || '저장에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveToMyDeck() {
|
||||
if (!window.UaDeckApi?.getToken()) {
|
||||
const redirect = encodeURIComponent(location.pathname + location.search);
|
||||
location.href = `/login.html?redirect=${redirect}`;
|
||||
return;
|
||||
}
|
||||
const isPublic = await resolveIsPublicForSave();
|
||||
if (isPublic === null) return;
|
||||
await executeDeckSave(isPublic);
|
||||
}
|
||||
|
||||
async function saveAsImage() {
|
||||
const loader = beginSaveLoading(t('generatingImage', '이미지 생성 중...'));
|
||||
|
||||
const deckName = getDeckSaveName();
|
||||
|
||||
try {
|
||||
loader.setProgress(10);
|
||||
loader.startAuto(85);
|
||||
const dataUrl = await capturePreviewPng();
|
||||
loader.stopAuto();
|
||||
loader.setProgress(92, t('generatingImage', '이미지 생성 중...'));
|
||||
const now = new Date();
|
||||
const timestamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`;
|
||||
const fileNameStr = `uadeck_${deckName}_${timestamp}`;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.download = `${fileNameStr}.png`;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
await loader.complete(t('downloadComplete', '다운로드 완료'));
|
||||
} catch (e) {
|
||||
console.error('Capture Error:', e);
|
||||
loader.hide();
|
||||
alert(translations.saveError || '이미지 저장 실패');
|
||||
}
|
||||
}
|
||||
|
||||
function goBackToCardList() {
|
||||
window.history.back();
|
||||
if (window.history.length > 1) {
|
||||
window.history.back();
|
||||
return;
|
||||
}
|
||||
const lang =
|
||||
new URLSearchParams(window.location.search).get('lang') ||
|
||||
localStorage.getItem('lang') ||
|
||||
'ko';
|
||||
const name = new URLSearchParams(window.location.search).get('seriesName') || '';
|
||||
window.location.href = `/cardList.html?series=${seriesKey}&seriesName=${encodeURIComponent(name)}&lang=${lang}`;
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
const lang =
|
||||
new URLSearchParams(window.location.search).get('lang') ||
|
||||
localStorage.getItem('lang') ||
|
||||
'ko';
|
||||
window.location.href = lang && lang !== 'ko' ? `/?lang=${lang}` : '/';
|
||||
}
|
||||
</script>
|
||||
<script src="/script/auth-header.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user