즐겨찾기·홀드 미리보기·페이지 도움말·3개국어 번역을 추가하고, 덱 저장 시 썸네일을 1200px로 통일하며 Nginx/API 업로드 제한을 상향했다. Co-authored-by: Cursor <cursoragent@cursor.com>
1224 lines
66 KiB
HTML
1224 lines
66 KiB
HTML
<!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/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>
|
|
body {
|
|
background-color: #f8fafc;
|
|
}
|
|
|
|
.result-container {
|
|
background-image: radial-gradient(#e2e8f0 1px, transparent 1px);
|
|
background-size: 20px 20px;
|
|
}
|
|
|
|
/* html2canvas 에러 방지용: 캡처 시 oklch 색상을 대체 */
|
|
.capture-mode {
|
|
background-image: none !important;
|
|
background-color: #ffffff !important;
|
|
}
|
|
|
|
/* Tailwind v4의 oklch 방지용 명시적 색상 지정 */
|
|
.capture-mode [class*='bg-blue-'],
|
|
.capture-mode [class*='text-blue-'] {
|
|
background-color: #2563eb !important;
|
|
color: #ffffff !important;
|
|
}
|
|
|
|
.card-badge {
|
|
position: absolute;
|
|
top: -6px;
|
|
right: -6px;
|
|
background-color: #2563eb;
|
|
/* oklch 대신 일반 hex 사용 */
|
|
color: white;
|
|
width: 24px;
|
|
height: 24px;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 12px;
|
|
line-height: 1;
|
|
font-weight: bold;
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
|
z-index: 10;
|
|
}
|
|
|
|
.capture-safe-border {
|
|
/* border: 1px solid #e2e8f0 !important; */
|
|
/* 명시적인 연한 회색 */
|
|
box-shadow: none !important;
|
|
outline: none !important;
|
|
}
|
|
|
|
.key-label {
|
|
background-color: #4f46e5 !important;
|
|
color: #ffffff !important;
|
|
border: none !important;
|
|
}
|
|
|
|
.card-wrapper {
|
|
position: relative;
|
|
padding: 8px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
}
|
|
|
|
.card-image-box {
|
|
position: relative;
|
|
width: 100%;
|
|
aspect-ratio: 2/3;
|
|
border-radius: 0.5rem;
|
|
background-color: white;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.card-badge {
|
|
position: absolute;
|
|
top: 0px;
|
|
right: 0px;
|
|
background-color: #2563eb !important;
|
|
color: white !important;
|
|
width: 26px;
|
|
height: 26px;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 13px;
|
|
font-weight: bold;
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
|
z-index: 20;
|
|
border: 2px solid white;
|
|
}
|
|
|
|
#resultArea * {
|
|
outline: none !important;
|
|
-webkit-tap-highlight-color: transparent;
|
|
}
|
|
|
|
.key-card-border {
|
|
box-shadow: none !important;
|
|
outline: none !important;
|
|
}
|
|
|
|
.capture-safe-border {
|
|
box-shadow: none !important;
|
|
outline: none !important;
|
|
}
|
|
|
|
.capture-grid-pc {
|
|
display: grid !important;
|
|
grid-template-columns: repeat(5, 1fr) !important;
|
|
gap: 16px !important;
|
|
width: 100% !important;
|
|
}
|
|
|
|
.capture-grid-classic {
|
|
display: grid !important;
|
|
grid-template-columns: repeat(5, 1fr) !important;
|
|
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 {
|
|
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;
|
|
flex: 1 1 0;
|
|
min-width: 0;
|
|
}
|
|
|
|
.result-btn-home,
|
|
.result-btn-download {
|
|
flex: 0.5 1 0;
|
|
}
|
|
|
|
.result-btn-save.result-btn-save-footer {
|
|
flex: 2 1 0;
|
|
}
|
|
|
|
.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-switch {
|
|
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;
|
|
}
|
|
|
|
.save-deck-switch.is-on {
|
|
background: #2563eb;
|
|
}
|
|
|
|
.save-deck-switch-knob {
|
|
display: 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);
|
|
}
|
|
|
|
.save-deck-switch.is-on .save-deck-switch-knob {
|
|
transform: translateX(1.5rem);
|
|
}
|
|
|
|
.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;
|
|
white-space: pre-line;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.save-loading-message.is-success {
|
|
color: #059669;
|
|
}
|
|
|
|
.save-loading-message.is-error {
|
|
color: #dc2626;
|
|
}
|
|
|
|
.save-loading-progress-wrap.hidden {
|
|
display: none;
|
|
}
|
|
|
|
.save-loading-confirm {
|
|
display: block;
|
|
width: 100%;
|
|
margin-top: 1.25rem;
|
|
padding: 0.875rem 1rem;
|
|
border: none;
|
|
border-radius: 0.75rem;
|
|
font-size: 0.875rem;
|
|
font-weight: 700;
|
|
color: #fff;
|
|
background: #2563eb;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.save-loading-confirm.hidden {
|
|
display: none;
|
|
}
|
|
|
|
@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="save-loading-overlay">
|
|
<div class="save-loading-box">
|
|
<p id="loading-message" class="save-loading-message"></p>
|
|
<div id="loading-progress-wrap" class="save-loading-progress-wrap">
|
|
<div class="save-loading-bar-track">
|
|
<div id="loading-bar-fill" class="save-loading-bar-fill"></div>
|
|
</div>
|
|
</div>
|
|
<p id="loading-submessage" class="save-loading-submessage"></p>
|
|
<button type="button" id="loading-confirm-btn" class="save-loading-confirm hidden">확인</button>
|
|
</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">
|
|
<aside class="w-full lg:w-80 space-y-4 lg:sticky lg:top-20 lg:h-fit">
|
|
<div class="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm space-y-6">
|
|
<div>
|
|
<label id="i18n-labelDeckName" class="block text-xs font-bold text-slate-400 uppercase mb-2">Deck Name</label>
|
|
<input type="text" id="deckName" class="w-full border border-slate-200 rounded-xl p-3 focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm" placeholder="미입력시 시리즈명 사용" />
|
|
</div>
|
|
|
|
<div>
|
|
<label id="i18n-labelExportStyle" class="block text-xs font-bold text-slate-400 uppercase mb-3">Export Style</label>
|
|
<div class="grid grid-cols-1 gap-2">
|
|
<button onclick="changeStyle('type1')" id="btn-type1" class="style-btn border-2 border-blue-600 bg-blue-50 text-blue-700 p-3 rounded-xl text-left transition-all">
|
|
<div id="i18n-type1Title" class="font-bold text-sm">Type 1: Signature</div>
|
|
<div id="i18n-type1Desc" class="text-[10px] opacity-70">키 카드를 강조하는 기본 스타일</div>
|
|
</button>
|
|
<button onclick="changeStyle('type2')" id="btn-type2" class="style-btn border-2 border-slate-100 hover:border-slate-200 p-3 rounded-xl text-left transition-all text-slate-500">
|
|
<div id="i18n-type2Title" class="font-bold text-sm">Type 2: Classic</div>
|
|
<div id="i18n-type2Desc" class="text-[10px] opacity-70">5열 균등 배치</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="pt-4 border-t border-slate-100">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<div id="i18n-hqTitle" class="text-sm font-bold text-slate-700">High Quality</div>
|
|
<div id="i18n-hqDesc" class="text-[10px] text-slate-400">용량 2배, 선명도 4배</div>
|
|
</div>
|
|
<button onclick="toggleHQ()" id="hq-toggle" class="relative inline-flex h-6 w-11 items-center rounded-full bg-slate-200 transition-colors">
|
|
<span id="hq-circle" class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform translate-x-1"></span>
|
|
</button>
|
|
<input type="checkbox" id="highQuality" class="hidden" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="i18n-notice" class="p-4 text-[11px] text-slate-400 leading-relaxed">
|
|
<p id="i18n-notice1">* ...</p>
|
|
<p id="i18n-notice2">* ...</p>
|
|
</div>
|
|
</aside>
|
|
|
|
<main class="flex-1">
|
|
<div class="bg-white rounded-3xl border border-slate-200 shadow-xl overflow-hidden result-container">
|
|
<div id="resultArea" class="p-8 sm:p-12 min-h-[600px] flex flex-col items-center"></div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
|
|
<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 result-btn-save-footer">내 덱으로 저장</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="save-deck-modal" class="save-deck-modal">
|
|
<div class="save-deck-modal-box">
|
|
<h3 id="save-modal-title">모두에게 공개할까요?</h3>
|
|
<p id="save-modal-desc" class="save-deck-modal-desc">공개하면「공개 덱」목록에 표시됩니다.</p>
|
|
<div class="save-deck-toggle-row">
|
|
<div>
|
|
<div id="save-public-label" class="save-deck-toggle-label">공개</div>
|
|
<div id="save-public-hint" class="save-deck-toggle-hint">기본값: 예</div>
|
|
</div>
|
|
<button type="button" id="save-public-toggle" class="save-deck-switch is-on" aria-pressed="true">
|
|
<span id="save-public-circle" class="save-deck-switch-knob"></span>
|
|
</button>
|
|
<input type="checkbox" id="save-public-check" class="hidden" checked />
|
|
</div>
|
|
<div class="save-deck-toggle-row">
|
|
<div>
|
|
<div id="save-skip-label" class="save-deck-toggle-label">다음부터 물어보지 않기</div>
|
|
<div id="save-skip-hint" class="save-deck-toggle-hint">선택한 공개 여부가 기본값이 됩니다</div>
|
|
</div>
|
|
<button type="button" id="save-skip-toggle" class="save-deck-switch" aria-pressed="false">
|
|
<span id="save-skip-circle" class="save-deck-switch-knob"></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 = [];
|
|
let seriesKey = '';
|
|
|
|
// 전역 함수로 등록 (onclick 이벤트 대응)
|
|
window.changeStyle = changeStyle;
|
|
window.saveAsImage = saveAsImage;
|
|
window.saveToMyDeck = saveToMyDeck;
|
|
window.toggleHQ = toggleHQ;
|
|
window.goBackToCardList = goBackToCardList;
|
|
window.goHome = goHome;
|
|
|
|
let translations = {};
|
|
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const currentLang = urlParams.get('lang') || localStorage.getItem('lang') || 'ko';
|
|
|
|
// 1. 다국어 데이터 로드
|
|
try {
|
|
const response = await fetch(`./i18n/translations.${currentLang}.json`);
|
|
translations = await response.json();
|
|
applyTranslationsToUI();
|
|
} catch (e) {
|
|
console.error('i18n 로드 실패:', e);
|
|
}
|
|
// 2. 카드 데이터 로드 및 렌더링
|
|
let cardsParam = localStorage.getItem('selectedCards');
|
|
seriesKey = urlParams.get('series') || localStorage.getItem('selectedSeries');
|
|
|
|
if (cardsParam && seriesKey) {
|
|
try {
|
|
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() {
|
|
const setEl = (id, key, isHTML = false) => {
|
|
const el = document.getElementById(id);
|
|
if (el && translations[key]) {
|
|
isHTML ? el.innerHTML = translations[key] : el.textContent = translations[key];
|
|
}
|
|
};
|
|
|
|
setEl('i18n-editDeck', 'editDeck');
|
|
setEl('i18n-previewTitle', 'deckPreview');
|
|
setEl('i18n-labelDeckName', 'deckNameLabel');
|
|
setEl('i18n-labelExportStyle', 'exportStyle');
|
|
setEl('i18n-type1Title', 'type1Title');
|
|
setEl('i18n-type1Desc', 'type1Desc');
|
|
setEl('i18n-type2Title', 'type2Title');
|
|
setEl('i18n-type2Desc', 'type2Desc');
|
|
setEl('i18n-hqTitle', 'highQuality');
|
|
setEl('i18n-hqDesc', 'hqDesc');
|
|
setEl('i18n-notice1', 'exportNotice1');
|
|
setEl('i18n-notice2', 'exportNotice2');
|
|
setEl('i18n-btnDownload', 'downloadImageLabel');
|
|
setEl('btn-save-deck', 'saveToMyDeckLabel', false);
|
|
setEl('btn-go-home', 'goHome', false);
|
|
setEl('save-modal-title', 'savePublicTitle');
|
|
setEl('save-modal-desc', 'savePublicDesc');
|
|
setEl('save-public-label', 'savePublicLabel');
|
|
setEl('save-public-hint', 'savePublicDefault');
|
|
setEl('save-skip-label', 'saveSkipLabel');
|
|
setEl('save-skip-hint', 'saveSkipHint');
|
|
setEl('save-deck-cancel', 'cancel');
|
|
setEl('save-deck-confirm', 'saveButton');
|
|
setEl('loading-confirm-btn', 'okButton');
|
|
|
|
const deckInput = document.getElementById('deckName');
|
|
if (deckInput) deckInput.placeholder = translations.deckNamePlaceholder || "";
|
|
}
|
|
|
|
function renderResult() {
|
|
const container = document.getElementById('resultArea');
|
|
if (!container) return;
|
|
container.innerHTML = '';
|
|
|
|
if (currentStyle === 'type1') {
|
|
renderType1(container);
|
|
} else {
|
|
renderType2(container);
|
|
}
|
|
}
|
|
|
|
// 타입 1: 키카드 강조형
|
|
function renderType1(container) {
|
|
const keyCards = selectedCards.filter((c) => c.key);
|
|
const others = selectedCards.filter((c) => !c.key);
|
|
|
|
if (keyCards.length > 0) {
|
|
const keyRow = document.createElement('div');
|
|
keyRow.className = 'flex flex-wrap justify-center gap-4 mb-12';
|
|
keyCards.forEach((card) => {
|
|
keyRow.appendChild(createCardUI(card, 'w-48 lg:w-56', true));
|
|
});
|
|
container.appendChild(keyRow);
|
|
}
|
|
|
|
const otherGrid = document.createElement('div');
|
|
otherGrid.className = 'grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-5 gap-4 w-full';
|
|
others.forEach((card) => {
|
|
otherGrid.appendChild(createCardUI(card, 'w-full', false));
|
|
});
|
|
container.appendChild(otherGrid);
|
|
}
|
|
|
|
// 타입 2: 클래식 5열 그리드
|
|
function renderType2(container) {
|
|
const sortedCards = [...selectedCards].sort((a, b) => (b.key ? 1 : 0) - (a.key ? 1 : 0));
|
|
|
|
const grid = document.createElement('div');
|
|
grid.className = 'grid grid-cols-3 sm:grid-cols-4 lg:grid-cols-5 gap-6 w-full';
|
|
sortedCards.forEach((card) => {
|
|
grid.appendChild(createCardUI(card, 'w-full', card.key));
|
|
});
|
|
container.appendChild(grid);
|
|
}
|
|
|
|
function createCardUI(card, widthClass, isKey) {
|
|
const div = document.createElement('div');
|
|
div.className = `${widthClass} card-wrapper`;
|
|
|
|
const fileName = card.imgSrc.split('/').pop();
|
|
const imgSrc = `/images/${seriesKey}/${fileName}`;
|
|
|
|
const keyLabelText = translations.keyCardLabel || "KEY CARD";
|
|
|
|
div.innerHTML = `
|
|
<div class="card-badge">${card.count}</div>
|
|
<div class="card-image-box ${isKey ? 'key-card-highlight' : ''}">
|
|
<img src="${imgSrc}" class="w-full h-full object-contain" crossorigin="anonymous">
|
|
${isKey ? `<div class="absolute bottom-0 left-0 right-0 key-label text-[10px] text-center py-1 font-bold">${keyLabelText}</div>` : ''}
|
|
</div>
|
|
<div class="mt-2 text-[10px] font-mono text-slate-400">${card.Number}</div>
|
|
`;
|
|
return div;
|
|
}
|
|
|
|
function changeStyle(style) {
|
|
currentStyle = style;
|
|
document.querySelectorAll('.style-btn').forEach((btn) => {
|
|
btn.classList.remove('border-blue-600', 'bg-blue-50', 'text-blue-700');
|
|
btn.classList.add('border-slate-100', 'text-slate-500');
|
|
});
|
|
const activeBtn = document.getElementById(`btn-${style}`);
|
|
if (activeBtn) {
|
|
activeBtn.classList.replace('border-slate-100', 'border-blue-600');
|
|
activeBtn.classList.add('bg-blue-50', 'text-blue-700');
|
|
}
|
|
renderResult();
|
|
}
|
|
|
|
function toggleHQ() {
|
|
const cb = document.getElementById('highQuality');
|
|
const toggle = document.getElementById('hq-toggle');
|
|
const circle = document.getElementById('hq-circle');
|
|
|
|
cb.checked = !cb.checked;
|
|
toggle.classList.toggle('bg-blue-600', cb.checked);
|
|
toggle.classList.toggle('bg-slate-200', !cb.checked);
|
|
circle.style.transform = cb.checked ? 'translateX(20px)' : 'translateX(0px)';
|
|
}
|
|
|
|
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 캡처
|
|
* @param {{ highQuality?: boolean }} [options]
|
|
* @returns {Promise<string>}
|
|
*/
|
|
async function capturePreviewPng(options = {}) {
|
|
const area = document.getElementById('resultArea');
|
|
const hq =
|
|
options.highQuality ??
|
|
document.getElementById('highQuality').checked;
|
|
const originalStyle = area.getAttribute('style') || '';
|
|
const gridElements = area.querySelectorAll('div[class*="grid"]');
|
|
const originalGrids = Array.from(gridElements).map((el) => el.className);
|
|
|
|
area.style.width = '1200px';
|
|
area.style.minWidth = '1200px';
|
|
area.style.display = 'block';
|
|
|
|
if (currentStyle === 'type1') {
|
|
const otherGrid = area.querySelector('div.grid');
|
|
if (otherGrid) otherGrid.className = 'capture-grid-pc';
|
|
} else {
|
|
const mainGrid = area.querySelector('div.grid');
|
|
if (mainGrid) mainGrid.className = 'capture-grid-classic';
|
|
}
|
|
|
|
const allCardWrappers = area.querySelectorAll('.card-wrapper');
|
|
allCardWrappers.forEach((wrapper) => {
|
|
const box = wrapper.querySelector('.card-image-box');
|
|
if (box) {
|
|
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';
|
|
}
|
|
});
|
|
|
|
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';
|
|
|
|
try {
|
|
return await htmlToImage.toPng(area, {
|
|
pixelRatio: scale,
|
|
backgroundColor: '#ffffff',
|
|
cacheBust: true,
|
|
canvasWidth: (area.offsetWidth + 80) * scale,
|
|
canvasHeight: (area.offsetHeight + 80) * scale,
|
|
});
|
|
} finally {
|
|
area.style.display = originalDisplay;
|
|
area.setAttribute('style', originalStyle);
|
|
gridElements.forEach((el, i) => {
|
|
el.className = originalGrids[i];
|
|
});
|
|
allCardWrappers.forEach((wrapper) => {
|
|
wrapper.style.width = '';
|
|
});
|
|
}
|
|
}
|
|
|
|
/** 저장·다운로드 캡처와 동일한 미리보기 기준 너비(px) */
|
|
const DECK_PREVIEW_SAVE_WIDTH = 1200;
|
|
|
|
/**
|
|
* 저장용 미리보기를 기준 너비로 정규화
|
|
* @param {string} dataUrl
|
|
* @param {number} [targetWidth]
|
|
* @returns {Promise<string>}
|
|
*/
|
|
async function normalizePreviewForSave(
|
|
dataUrl,
|
|
targetWidth = DECK_PREVIEW_SAVE_WIDTH,
|
|
) {
|
|
return new Promise((resolve, reject) => {
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
if (!img.width) {
|
|
resolve(dataUrl);
|
|
return;
|
|
}
|
|
const scale = targetWidth / img.width;
|
|
const w = targetWidth;
|
|
const h = Math.max(1, Math.round(img.height * scale));
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = w;
|
|
canvas.height = h;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) {
|
|
resolve(dataUrl);
|
|
return;
|
|
}
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.fillRect(0, 0, w, h);
|
|
ctx.drawImage(img, 0, 0, w, h);
|
|
resolve(canvas.toDataURL('image/png'));
|
|
};
|
|
img.onerror = () => reject(new Error('Preview resize failed'));
|
|
img.src = dataUrl;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 저장·공유 로딩 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');
|
|
const progressWrap = document.getElementById('loading-progress-wrap');
|
|
const confirmBtn = document.getElementById('loading-confirm-btn');
|
|
let autoTimer = null;
|
|
let current = 0;
|
|
|
|
const resetUi = () => {
|
|
msgEl.classList.remove('is-success', 'is-error');
|
|
progressWrap.classList.remove('hidden');
|
|
confirmBtn.classList.add('hidden');
|
|
confirmBtn.onclick = null;
|
|
fillEl.style.width = '0%';
|
|
subEl.textContent = '';
|
|
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);
|
|
};
|
|
|
|
resetUi();
|
|
msgEl.textContent = message;
|
|
screen.style.display = 'flex';
|
|
|
|
/**
|
|
* 로딩 완료 → 결과 메시지 표시
|
|
* @param {object} opts
|
|
* @param {string} opts.title
|
|
* @param {string} [opts.subtitle]
|
|
* @param {boolean} [opts.redirectHome]
|
|
* @param {number} [opts.redirectDelay]
|
|
*/
|
|
const finishSuccess = async ({
|
|
title,
|
|
subtitle = '',
|
|
redirectHome = false,
|
|
redirectDelay = 1400,
|
|
}) => {
|
|
stopAuto();
|
|
setProgress(100);
|
|
await new Promise((r) => setTimeout(r, 380));
|
|
progressWrap.classList.add('hidden');
|
|
msgEl.textContent = title;
|
|
msgEl.classList.add('is-success');
|
|
subEl.textContent = redirectHome
|
|
? [subtitle, t('redirectingHome', '잠시 후 홈으로 이동합니다.')].filter(Boolean).join('\n')
|
|
: subtitle;
|
|
if (redirectHome) {
|
|
await new Promise((r) => setTimeout(r, redirectDelay));
|
|
screen.style.display = 'none';
|
|
resetUi();
|
|
goHome();
|
|
return;
|
|
}
|
|
await new Promise((r) => setTimeout(r, 700));
|
|
screen.style.display = 'none';
|
|
resetUi();
|
|
};
|
|
|
|
/**
|
|
* 오류 메시지 표시
|
|
* @param {string} title
|
|
* @param {string} [subtitle]
|
|
*/
|
|
const finishError = (title, subtitle = '') => {
|
|
stopAuto();
|
|
progressWrap.classList.add('hidden');
|
|
fillEl.style.width = '100%';
|
|
msgEl.textContent = title;
|
|
msgEl.classList.add('is-error');
|
|
subEl.textContent = subtitle;
|
|
confirmBtn.classList.remove('hidden');
|
|
confirmBtn.onclick = () => {
|
|
screen.style.display = 'none';
|
|
resetUi();
|
|
};
|
|
};
|
|
|
|
return {
|
|
setProgress,
|
|
setSubmessage: (text) => {
|
|
subEl.textContent = text;
|
|
},
|
|
startAuto,
|
|
stopAuto,
|
|
finishSuccess,
|
|
finishError,
|
|
complete: async (doneMessage) => {
|
|
await finishSuccess({ title: doneMessage });
|
|
},
|
|
hide: () => {
|
|
stopAuto();
|
|
screen.style.display = 'none';
|
|
resetUi();
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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 checkbox = document.getElementById(checkboxId);
|
|
if (!toggle || !checkbox) return;
|
|
checkbox.checked = on;
|
|
toggle.classList.toggle('is-on', on);
|
|
toggle.setAttribute('aria-pressed', on ? 'true' : 'false');
|
|
}
|
|
|
|
/**
|
|
* 토글 클릭 바인딩
|
|
* @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();
|
|
const loader = beginSaveLoading(
|
|
t('savingToMyDeck', '내 덱으로 저장 중...'),
|
|
);
|
|
if (!cards.length) {
|
|
loader.finishError(
|
|
t('noCardsToSave', '저장할 카드가 없습니다.'),
|
|
'',
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
loader.setProgress(8, t('savingPreview', '미리보기 생성 중...'));
|
|
loader.startAuto(52);
|
|
let previewImage = null;
|
|
try {
|
|
previewImage = await capturePreviewPng({ highQuality: false });
|
|
if (previewImage) {
|
|
previewImage = await normalizePreviewForSave(previewImage);
|
|
}
|
|
} 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 subtitle = !previewImage
|
|
? `${deck.name}\n${t('previewFailedNote', '(미리보기 이미지 생성에 실패했습니다)')}`
|
|
: deck.name;
|
|
await loader.finishSuccess({
|
|
title: isPublic
|
|
? t('deckSavedPublic', '덱이 공개로 저장되었습니다.')
|
|
: t('deckSavedPrivate', '덱이 비공개로 저장되었습니다.'),
|
|
subtitle,
|
|
redirectHome: true,
|
|
});
|
|
} catch (e) {
|
|
console.error(e);
|
|
loader.finishError(
|
|
t('saveFailedTitle', '저장에 실패했습니다.'),
|
|
e.message || t('tryAgain', '다시 시도해 주세요.'),
|
|
);
|
|
}
|
|
}
|
|
|
|
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.finishSuccess({
|
|
title: t('downloadComplete', '다운로드 완료'),
|
|
subtitle: `${fileNameStr}.png`,
|
|
});
|
|
} catch (e) {
|
|
console.error('Capture Error:', e);
|
|
loader.finishError(
|
|
t('saveError', '이미지 저장 실패'),
|
|
t('tryAgain', '다시 시도해 주세요.'),
|
|
);
|
|
}
|
|
}
|
|
|
|
function goBackToCardList() {
|
|
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/page-i18n.js"></script>
|
|
<script src="/script/auth-header.js"></script>
|
|
</body>
|
|
</html>
|