v4.0.0: 덱 UX·i18n·썸네일 저장 개선 및 413 수정
즐겨찾기·홀드 미리보기·페이지 도움말·3개국어 번역을 추가하고, 덱 저장 시 썸네일을 1200px로 통일하며 Nginx/API 업로드 제한을 상향했다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
311
result.html
311
result.html
@@ -158,7 +158,6 @@
|
||||
}
|
||||
|
||||
.result-btn {
|
||||
flex: 1;
|
||||
padding: 1rem 0.5rem;
|
||||
border: none;
|
||||
border-radius: 1rem;
|
||||
@@ -168,6 +167,17 @@
|
||||
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 {
|
||||
@@ -252,6 +262,40 @@
|
||||
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;
|
||||
@@ -323,6 +367,38 @@
|
||||
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) {
|
||||
@@ -338,10 +414,13 @@
|
||||
<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 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>
|
||||
|
||||
@@ -404,32 +483,32 @@
|
||||
<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>
|
||||
<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>모두에게 공개할까요?</h3>
|
||||
<p class="save-deck-modal-desc">공개하면「공개 덱」목록에 표시됩니다.</p>
|
||||
<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 class="save-deck-toggle-label">공개</div>
|
||||
<div class="save-deck-toggle-hint">기본값: 예</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="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 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 class="save-deck-toggle-label">다음부터 물어보지 않기</div>
|
||||
<div class="save-deck-toggle-hint">선택한 공개 여부가 기본값이 됩니다</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="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 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>
|
||||
@@ -505,9 +584,18 @@
|
||||
setEl('i18n-hqDesc', 'hqDesc');
|
||||
setEl('i18n-notice1', 'exportNotice1');
|
||||
setEl('i18n-notice2', 'exportNotice2');
|
||||
setEl('i18n-btnDownload', 'downloadImage');
|
||||
setEl('btn-save-deck', 'saveToMyDeck', false);
|
||||
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 || "";
|
||||
@@ -667,11 +755,14 @@
|
||||
|
||||
/**
|
||||
* 미리보기 영역 PNG 캡처
|
||||
* @param {{ highQuality?: boolean }} [options]
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function capturePreviewPng() {
|
||||
async function capturePreviewPng(options = {}) {
|
||||
const area = document.getElementById('resultArea');
|
||||
const hq = document.getElementById('highQuality').checked;
|
||||
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);
|
||||
@@ -731,6 +822,47 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** 저장·다운로드 캡처와 동일한 미리보기 기준 너비(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
|
||||
@@ -741,9 +873,21 @@
|
||||
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);
|
||||
@@ -766,12 +910,64 @@
|
||||
}, 120);
|
||||
};
|
||||
|
||||
resetUi();
|
||||
msgEl.textContent = message;
|
||||
subEl.textContent = '';
|
||||
fillEl.style.width = '0%';
|
||||
current = 0;
|
||||
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) => {
|
||||
@@ -779,15 +975,15 @@
|
||||
},
|
||||
startAuto,
|
||||
stopAuto,
|
||||
finishSuccess,
|
||||
finishError,
|
||||
complete: async (doneMessage) => {
|
||||
stopAuto();
|
||||
setProgress(100, doneMessage);
|
||||
await new Promise((r) => setTimeout(r, 450));
|
||||
screen.style.display = 'none';
|
||||
await finishSuccess({ title: doneMessage });
|
||||
},
|
||||
hide: () => {
|
||||
stopAuto();
|
||||
screen.style.display = 'none';
|
||||
resetUi();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -811,14 +1007,11 @@
|
||||
*/
|
||||
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;
|
||||
if (!toggle || !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);
|
||||
toggle.classList.toggle('is-on', on);
|
||||
toggle.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -906,20 +1099,26 @@
|
||||
*/
|
||||
async function executeDeckSave(isPublic) {
|
||||
const cards = getCardsPayload();
|
||||
if (!cards.length) {
|
||||
alert('저장할 카드가 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
previewImage = await capturePreviewPng({ highQuality: false });
|
||||
if (previewImage) {
|
||||
previewImage = await normalizePreviewForSave(previewImage);
|
||||
}
|
||||
} catch (captureErr) {
|
||||
console.warn('Preview capture failed:', captureErr);
|
||||
}
|
||||
@@ -935,20 +1134,22 @@
|
||||
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}`);
|
||||
}
|
||||
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.hide();
|
||||
alert(e.message || '저장에 실패했습니다.');
|
||||
loader.finishError(
|
||||
t('saveFailedTitle', '저장에 실패했습니다.'),
|
||||
e.message || t('tryAgain', '다시 시도해 주세요.'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -982,11 +1183,16 @@
|
||||
link.download = `${fileNameStr}.png`;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
await loader.complete(t('downloadComplete', '다운로드 완료'));
|
||||
await loader.finishSuccess({
|
||||
title: t('downloadComplete', '다운로드 완료'),
|
||||
subtitle: `${fileNameStr}.png`,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Capture Error:', e);
|
||||
loader.hide();
|
||||
alert(translations.saveError || '이미지 저장 실패');
|
||||
loader.finishError(
|
||||
t('saveError', '이미지 저장 실패'),
|
||||
t('tryAgain', '다시 시도해 주세요.'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1011,6 +1217,7 @@
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user