diff --git a/api/src/index.js b/api/src/index.js index 0e0519b..f8cb7ea 100644 --- a/api/src/index.js +++ b/api/src/index.js @@ -26,7 +26,7 @@ const corsOrigins = process.env.CORS_ORIGINS : true; app.use(cors({ origin: corsOrigins, credentials: true })); -app.use(express.json({ limit: '15mb' })); +app.use(express.json({ limit: '20mb' })); app.get('/api/v1/health', (_req, res) => { res.json({ ok: true, version: '4.0.0' }); @@ -37,6 +37,11 @@ app.use('/api/v1/decks', createDecksRouter(db)); app.use('/api/v1/admin', createAdminRouter(db)); app.use((err, _req, res, _next) => { + if (err.type === 'entity.too.large') { + return res.status(413).json({ + error: '요청 크기가 너무 큽니다. 고화질 옵션을 끄고 다시 저장해 주세요.', + }); + } console.error(err); res.status(500).json({ error: '서버 오류가 발생했습니다.' }); }); diff --git a/cardList.html b/cardList.html index 617c214..b8661a4 100644 --- a/cardList.html +++ b/cardList.html @@ -83,7 +83,7 @@
- +
검색결과 : @@ -478,6 +478,7 @@ }; safeSet('i18n-back', 'back'); + safeSet('i18n-home', 'homeNav'); safeSet('i18n-searchResults', 'searchResults'); safeSet('i18n-resetBtn', 'resetSelection'); safeSet('i18n-labelSearch', 'search'); @@ -505,58 +506,89 @@ window.location.href = lang && lang !== 'ko' ? `/?lang=${lang}` : '/'; }; + /** + * 동일 카드 번호 그룹 합계 + * @param {string} cardNumber + * @returns {number} + */ + function getNumberGroupTotal(cardNumber) { + return cardList + .filter((c) => c.Number === cardNumber) + .reduce((sum, c) => sum + c.count, 0); + } + + /** + * 동일 카드 번호 그룹 최대 수량 + * @param {string} cardNumber + * @returns {number} + */ + function getGroupMaxCount(cardNumber) { + const cards = cardList.filter((c) => c.Number === cardNumber); + if (!cards.length) return 4; + return Math.max(...cards.map((c) => c.maxCount || 4)); + } + + /** + * 카드 컨테이너 조회 + * @param {string} imgSrc + * @returns {HTMLElement|null} + */ + function getCardContainer(imgSrc) { + return document.querySelector(`.cardContainer[data-imgsrc="${CSS.escape(imgSrc)}"]`); + } + + /** + * 동일 번호 카드들의 +/- UI 동기화 + * @param {string} cardNumber + */ + function updateCountControlsForNumber(cardNumber) { + const groupTotal = getNumberGroupTotal(cardNumber); + const groupAtMax = groupTotal >= getGroupMaxCount(cardNumber); + + cardList + .filter((c) => c.Number === cardNumber) + .forEach((sibling) => { + const container = getCardContainer(sibling.imgSrc); + if (!container) return; + + const minusBtn = container.querySelector('.minusBtn'); + const plusBtn = container.querySelector('.plusBtn'); + const countSpan = container.querySelector('.countText'); + const keyBtn = container.querySelector('.keyBtn'); + + if (countSpan) countSpan.textContent = sibling.count; + + if (minusBtn) { + minusBtn.classList.toggle('invisible', sibling.count === 0); + minusBtn.classList.toggle('visible', sibling.count > 0); + } + + if (plusBtn) { + plusBtn.classList.toggle('invisible', groupAtMax); + plusBtn.classList.toggle('visible', !groupAtMax); + } + + if (keyBtn) { + keyBtn.className = `keyBtn w-full py-1 rounded text-[9px] lg:text-[10px] font-bold border transition-all ${sibling.key && sibling.count > 0 ? 'bg-indigo-600 border-indigo-600 text-white shadow-md' : 'bg-white border-slate-200 text-slate-400'}`; + } + }); + } + window.handleCount = function (imgSrc, delta, event) { event.stopPropagation(); const card = cardList.find((c) => c.imgSrc === imgSrc); if (!card) return; - // 1. 데이터 업데이트 - const currentTotal = cardList.filter((c) => c.Number === card.Number).reduce((s, c) => s + c.count, 0); - if (delta > 0 && currentTotal < (card.maxCount || 4)) { + const maxCount = getGroupMaxCount(card.Number); + const currentTotal = getNumberGroupTotal(card.Number); + if (delta > 0 && currentTotal < maxCount) { card.count++; } else if (delta < 0 && card.count > 0) { card.count--; if (card.count === 0) card.key = false; } - // 2. UI 업데이트 - const container = document.querySelector(`.cardContainer[data-imgsrc="${imgSrc}"]`); - if (container) { - const minusBtn = container.querySelector('.minusBtn'); - const plusBtn = container.querySelector('.plusBtn'); - const countSpan = container.querySelector('.countText'); - const keyBtn = container.querySelector('.keyBtn'); // 클래스로 직접 참조 - - if (countSpan) countSpan.textContent = card.count; - - // 마이너스 버튼 제어 - if (minusBtn) { - if (card.count > 0) { - minusBtn.classList.remove('invisible'); - minusBtn.classList.add('visible'); - } else { - minusBtn.classList.remove('visible'); - minusBtn.classList.add('invisible'); - } - } - - // 플러스 버튼 제어 (isMax 결과에 따라 즉시 반영) - if (plusBtn) { - if (isMax(card)) { - plusBtn.classList.remove('visible'); - plusBtn.classList.add('invisible'); - } else { - plusBtn.classList.remove('invisible'); - plusBtn.classList.add('visible'); - } - } - - // 키 버튼 상태 업데이트 - if (keyBtn) { - keyBtn.className = `keyBtn w-full py-1 rounded text-[9px] lg:text-[10px] font-bold border transition-all ${card.key && card.count > 0 ? 'bg-indigo-600 border-indigo-600 text-white shadow-md' : 'bg-white border-slate-200 text-slate-400'}`; - } - } - + updateCountControlsForNumber(card.Number); syncToStorageAndCounters(); }; @@ -585,7 +617,7 @@ }; function isMax(card) { - return cardList.filter((c) => c.Number === card.Number).reduce((s, c) => s + c.count, 0) >= (card.maxCount || 4); + return getNumberGroupTotal(card.Number) >= getGroupMaxCount(card.Number); } function updateCardList() { diff --git a/deckHistory.html b/deckHistory.html index 49c38d8..5e1112a 100644 --- a/deckHistory.html +++ b/deckHistory.html @@ -26,41 +26,95 @@
-

내 덱 관리

-

저장한 덱 목록입니다. 공개 전환 시「공개 덱」목록에 표시됩니다.

+
+

내 덱 관리

+ +
-
불러오는 중...
+
불러오는 중...
+ + + + + + diff --git a/login.html b/login.html index c015c74..3fbc248 100644 --- a/login.html +++ b/login.html @@ -20,29 +20,30 @@
-

로그인

-

덱을 저장하고 관리하려면
계정으로 로그인하세요.

+

로그인

+

덱을 저장하고 관리하려면
계정으로 로그인하세요.

- +
- +
- +
+ + + + + + diff --git a/profile.html b/profile.html index 4f37995..c88cb76 100644 --- a/profile.html +++ b/profile.html @@ -26,13 +26,15 @@
-

내 프로필

-

프로필 사진, 사용자명, 이메일, 비밀번호를 변경할 수 있습니다.

+
+

내 프로필

+ +
-

불러오는 중...

+

불러오는 중...

@@ -54,15 +56,15 @@
- +
- +
- @@ -70,34 +72,45 @@
-

덱 저장 설정

-

「내 덱으로 저장」시 공개 여부 기본값과 확인 방식을 설정합니다.

- -
-
-
기본 공개
-
저장 시 기본으로 공개 덱에 노출
-
- - +
+

덱 저장 설정

+
-
-
-
저장 시 공개 여부 물어보기
-
끄면 설정한 기본값으로 바로 저장
+
+
+ +
+ ON + + +
+
+ +
+ +
+ ON + + +
- -
+

-
@@ -105,35 +118,47 @@
-

비밀번호 변경

+

비밀번호 변경

- +
- +
- +
- - + +
+ + + @@ -149,23 +174,46 @@ let pendingRemoveAvatar = false; /** @type {{ email: string, username: string, hasAvatar: boolean }} */ let baseline = { email: '', username: '', hasAvatar: false }; + /** @type {number|null} */ + let prefsNoticeTimer = null; + + /** + * @param {string} key + * @param {string} fallback + * @returns {string} + */ + function t(key, fallback) { + return window.UaPageI18n?.t(key, fallback) || fallback; + } /** * 프로필 토글 UI * @param {string} toggleId * @param {string} circleId * @param {string} checkboxId + * @param {string|null} stateId * @param {boolean} on */ - function setProfileToggle(toggleId, circleId, checkboxId, on) { + function setProfileToggle(toggleId, circleId, checkboxId, stateId, on) { const toggle = document.getElementById(toggleId); const circle = document.getElementById(circleId); const checkbox = document.getElementById(checkboxId); + const stateEl = stateId ? document.getElementById(stateId) : null; if (!toggle || !circle || !checkbox) return; checkbox.checked = on; toggle.setAttribute('aria-pressed', on ? 'true' : 'false'); toggle.classList.toggle('is-on', on); circle.classList.toggle('is-on', on); + if (stateEl) { + const onLabel = stateEl.dataset.i18nOn + ? t(stateEl.dataset.i18nOn, 'ON') + : 'ON'; + const offLabel = stateEl.dataset.i18nOff + ? t(stateEl.dataset.i18nOff, 'OFF') + : 'OFF'; + stateEl.textContent = on ? onLabel : offLabel; + stateEl.classList.toggle('is-on', on); + } } /** @@ -174,15 +222,35 @@ */ function fillDeckPrefs(user) { const prefs = UaDeckPrefs.getPrefs(user); - setProfileToggle('pref-public-toggle', 'pref-public-circle', 'pref-public-check', prefs.defaultPublic); + setProfileToggle( + 'pref-public-toggle', + 'pref-public-circle', + 'pref-public-check', + 'pref-public-state', + prefs.defaultPublic, + ); setProfileToggle( 'pref-ask-toggle', 'pref-ask-circle', 'pref-ask-check', + 'pref-ask-state', !prefs.skipPrompt, ); } + /** + * 덱 저장 설정 적용 안내 + */ + function showPrefsSavedNotice() { + const el = document.getElementById('profile-prefs-notice'); + if (!el) return; + el.textContent = t('profileDeckPrefsSaved', '덱 저장 설정이 적용되었습니다.'); + if (prefsNoticeTimer) clearTimeout(prefsNoticeTimer); + prefsNoticeTimer = window.setTimeout(() => { + el.textContent = ''; + }, 2500); + } + /** * 덱 저장 설정 저장 */ @@ -198,6 +266,7 @@ cached.skipDeckPublicPrompt = prefs.skipPrompt; UaDeckApi.saveAuth(UaDeckApi.getToken(), cached); } + showPrefsSavedNotice(); } /** @@ -275,6 +344,10 @@ } async function init() { + if (window.UaPageI18n?.applyPageI18n) { + await window.UaPageI18n.applyPageI18n(); + } + if (!UaDeckApi.getToken()) { location.href = '/login.html?redirect=' + encodeURIComponent('/profile.html'); return; @@ -306,6 +379,7 @@ 'pref-public-toggle', 'pref-public-circle', 'pref-public-check', + 'pref-public-state', !checkbox.checked, ); await persistDeckPrefs(); @@ -313,10 +387,25 @@ document.getElementById('pref-ask-toggle').addEventListener('click', async () => { const checkbox = document.getElementById('pref-ask-check'); - setProfileToggle('pref-ask-toggle', 'pref-ask-circle', 'pref-ask-check', !checkbox.checked); + setProfileToggle( + 'pref-ask-toggle', + 'pref-ask-circle', + 'pref-ask-check', + 'pref-ask-state', + !checkbox.checked, + ); await persistDeckPrefs(); }); + document.querySelectorAll('.profile-toggle-text').forEach((label) => { + label.addEventListener('click', (e) => { + const forId = label.getAttribute('for'); + if (!forId) return; + const btn = document.getElementById(forId); + if (btn && e.target !== btn) btn.click(); + }); + }); + document.getElementById('profile-avatar-btn').addEventListener('click', () => { document.getElementById('profile-avatar-file').click(); }); diff --git a/result.html b/result.html index 2d7f533..126bbcf 100644 --- a/result.html +++ b/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 @@

-
-
+
+
+
+

+
@@ -404,32 +483,32 @@
-

모두에게 공개할까요?

-

공개하면「공개 덱」목록에 표시됩니다.

+

모두에게 공개할까요?

+

공개하면「공개 덱」목록에 표시됩니다.

-
공개
-
기본값: 예
+
공개
+
기본값: 예
-
-
다음부터 물어보지 않기
-
선택한 공개 여부가 기본값이 됩니다
+
다음부터 물어보지 않기
+
선택한 공개 여부가 기본값이 됩니다
-
@@ -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} */ - 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} + */ + 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}` : '/'; } + diff --git a/script/ads.js b/script/ads.js new file mode 100644 index 0000000..6c9eb40 --- /dev/null +++ b/script/ads.js @@ -0,0 +1,20 @@ +/** + * Google AdSense 슬롯 초기화 + */ +(function initAds() { + function pushAds() { + document.querySelectorAll('ins.adsbygoogle').forEach(() => { + try { + (window.adsbygoogle = window.adsbygoogle || []).push({}); + } catch (err) { + console.warn('AdSense push failed:', err); + } + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', pushAds); + } else { + pushAds(); + } +})(); diff --git a/script/api.js b/script/api.js index 6241ce9..1d8c18f 100644 --- a/script/api.js +++ b/script/api.js @@ -50,6 +50,9 @@ const UaDeckApi = (() => { if (res.status === 405 || res.status === 404) { return 'API 서버에 연결되지 않았습니다. Docker 또는 npm run api:dev 로 API를 실행하세요.'; } + if (res.status === 413) { + return data.error || '업로드 크기가 제한을 초과했습니다. 고화질 옵션을 끄고 다시 시도해 주세요.'; + } if (res.status === 0 || res.type === 'opaque') { return '네트워크 오류입니다. API 서버가 실행 중인지 확인하세요.'; } diff --git a/script/auth-header.js b/script/auth-header.js index b8cf267..2459382 100644 --- a/script/auth-header.js +++ b/script/auth-header.js @@ -11,6 +11,18 @@ let currentUser = null; let dropdownOpen = false; + /** + * @param {string} key + * @param {string} fallback + * @returns {string} + */ + function t(key, fallback) { + if (window.UaPageI18n?.t) { + return window.UaPageI18n.t(key, fallback); + } + return fallback; + } + /** * HTML 이스케이프 * @param {string} str @@ -51,6 +63,16 @@ return src; } + /** + * 표시용 닉네임 + * @param {object|null} user + * @returns {string} + */ + function getDisplayName(user) { + if (!user) return ''; + return String(user.username || user.displayName || '').trim(); + } + /** * 아바타 HTML * @param {object|null} user @@ -62,18 +84,19 @@ } /** - * 프로필 버튼 HTML (아바타 + 사용자명) + * 프로필 버튼 HTML (아바타 + 닉네임) * @param {object|null} user * @returns {string} */ function profileBtnHtml(user) { + const name = getDisplayName(user); const avatar = avatarHtml(user); - if (!user?.username) { - return `${avatar}`; + if (!name) { + return `${avatar}`; } return ` - ${avatar} - ${escapeHtml(user.username)} + ${avatar} + ${escapeHtml(name)} `; } @@ -86,28 +109,28 @@ if (!currentUser) { dd.innerHTML = ` - 로그인 - 회원가입 + ${escapeHtml(t('authLogin', '로그인'))} + ${escapeHtml(t('authSignup', '회원가입'))} `; return; } const adminLink = currentUser.role === 'admin' - ? `관리자` + ? `${escapeHtml(t('authAdmin', '관리자'))}` : ''; dd.innerHTML = `
-
${currentUser.username}
-
${currentUser.email || ''}
+
${escapeHtml(getDisplayName(currentUser))}
+
${escapeHtml(currentUser.email || '')}
- 내 프로필 - 내 덱 관리 - 공개 덱 + ${escapeHtml(t('authMyProfile', '내 프로필'))} + ${escapeHtml(t('authMyDecks', '내 덱 관리'))} + ${escapeHtml(t('authPublicDecks', '공개 덱'))} ${adminLink}
- + `; const logoutBtn = document.getElementById('auth-logout-btn'); @@ -127,8 +150,14 @@ function renderHeader() { const btn = document.getElementById('auth-avatar-btn'); if (btn) { + const name = getDisplayName(currentUser); btn.innerHTML = profileBtnHtml(currentUser); - btn.classList.toggle('auth-profile-btn--named', Boolean(currentUser?.username)); + btn.classList.toggle('auth-profile-trigger--named', Boolean(name)); + btn.classList.toggle('auth-profile-trigger--guest', !name); + const menuLabel = name + ? t('authAccountMenuNamed', '{name} 계정 메뉴').replace('{name}', name) + : t('authAccountMenu', '계정 메뉴'); + btn.setAttribute('aria-label', menuLabel); } renderDropdown(); } @@ -140,7 +169,7 @@ function mount(slot) { slot.innerHTML = `
-
@@ -185,6 +214,10 @@ * 초기화 */ async function init() { + if (window.UaPageI18n?.applyPageI18n) { + await window.UaPageI18n.applyPageI18n(); + } + currentUser = window.UaDeckApi.getCachedUser(); await refreshCurrentUser(); diff --git a/script/deck-favorites.js b/script/deck-favorites.js new file mode 100644 index 0000000..6d6a7c9 --- /dev/null +++ b/script/deck-favorites.js @@ -0,0 +1,60 @@ +/** + * 덱 즐겨찾기 (localStorage, share_code 기준) + */ +(function initDeckFavorites() { + const STORAGE_KEY = 'uadeck.deckFavorites'; + + /** + * @returns {Set} + */ + function readSet() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + const list = raw ? JSON.parse(raw) : []; + return new Set(Array.isArray(list) ? list : []); + } catch { + return new Set(); + } + } + + /** + * @param {Set} set + */ + function writeSet(set) { + localStorage.setItem(STORAGE_KEY, JSON.stringify([...set])); + } + + /** + * @param {string} shareCode + * @returns {boolean} + */ + function isFavorite(shareCode) { + if (!shareCode) return false; + return readSet().has(shareCode); + } + + /** + * @param {string} shareCode + * @returns {boolean} 토글 후 즐겨찾기 여부 + */ + function toggle(shareCode) { + if (!shareCode) return false; + const set = readSet(); + if (set.has(shareCode)) { + set.delete(shareCode); + } else { + set.add(shareCode); + } + writeSet(set); + return set.has(shareCode); + } + + /** + * @returns {string[]} + */ + function getAll() { + return [...readSet()]; + } + + window.UaDeckFavorites = { isFavorite, toggle, getAll }; +})(); diff --git a/script/deck-preview-hold.js b/script/deck-preview-hold.js new file mode 100644 index 0000000..ca9a4b4 --- /dev/null +++ b/script/deck-preview-hold.js @@ -0,0 +1,179 @@ +/** + * 썸네일 미리보기 확대 (클릭 고정 / 길게 누르기) + */ +(function initDeckPreviewHold() { + /** @type {HTMLElement|null} */ + let overlay = null; + + /** @type {number} */ + let pressStartAt = 0; + + /** @type {boolean} */ + let suppressClick = false; + + /** @type {number} */ + const HOLD_THRESHOLD_MS = 280; + + /** + * @returns {HTMLElement} + */ + function ensureOverlay() { + if (!overlay) { + overlay = document.createElement('div'); + overlay.className = 'deck-preview-hold-overlay hidden'; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + overlay.innerHTML = + '
'; + overlay.addEventListener('click', (e) => { + if (e.target === overlay) hide(); + }); + document.body.appendChild(overlay); + } + return overlay; + } + + /** + * @returns {boolean} + */ + function isVisible() { + return Boolean(overlay && !overlay.classList.contains('hidden')); + } + + /** + * @param {string} src + */ + function show(src) { + if (!src) return; + const el = ensureOverlay(); + const img = el.querySelector('img'); + if (img) img.src = src; + el.classList.remove('hidden'); + } + + function hide() { + if (overlay) overlay.classList.add('hidden'); + pressStartAt = 0; + suppressClick = false; + } + + /** + * @param {Event} e + * @returns {HTMLElement|null} + */ + function findHoldTarget(e) { + const target = e.target; + if (!(target instanceof Element)) return null; + return target.closest('[data-hold-preview]'); + } + + /** + * @param {HTMLElement} hold + * @returns {string} + */ + function getPreviewSrc(hold) { + return hold.dataset.previewSrc || ''; + } + + function initGlobal() { + if (document.documentElement.dataset.deckHoldInit === '1') return; + document.documentElement.dataset.deckHoldInit = '1'; + + document.addEventListener( + 'pointerdown', + (e) => { + const hold = findHoldTarget(e); + if (!hold) return; + const src = getPreviewSrc(hold); + if (!src) return; + + pressStartAt = Date.now(); + show(src); + + if (hold.setPointerCapture && e.pointerId !== undefined) { + try { + hold.setPointerCapture(e.pointerId); + } catch { + /* ignore */ + } + } + }, + true, + ); + + document.addEventListener( + 'pointerup', + (e) => { + if (!pressStartAt) return; + + const hold = findHoldTarget(e); + if (hold?.releasePointerCapture && e.pointerId !== undefined) { + try { + hold.releasePointerCapture(e.pointerId); + } catch { + /* ignore */ + } + } + + const heldMs = Date.now() - pressStartAt; + pressStartAt = 0; + + if (heldMs >= HOLD_THRESHOLD_MS) { + hide(); + suppressClick = true; + window.setTimeout(() => { + suppressClick = false; + }, 0); + } else { + suppressClick = true; + window.setTimeout(() => { + suppressClick = false; + }, 400); + } + }, + true, + ); + + document.addEventListener( + 'pointercancel', + () => { + hide(); + }, + true, + ); + + document.addEventListener('click', (e) => { + if (suppressClick) return; + + const hold = findHoldTarget(e); + if (hold) { + const src = getPreviewSrc(hold); + if (!src) return; + e.preventDefault(); + e.stopPropagation(); + if (isVisible()) hide(); + else show(src); + return; + } + + if (isVisible() && e.target === overlay) { + hide(); + } + }); + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') hide(); + }); + } + + /** + * @param {HTMLElement} _container + */ + function bindHoldPreview(_container) { + initGlobal(); + } + + initGlobal(); + + window.UaDeckPreviewHold = { bindHoldPreview, hide }; +})(); diff --git a/script/page-help.js b/script/page-help.js new file mode 100644 index 0000000..a164041 --- /dev/null +++ b/script/page-help.js @@ -0,0 +1,96 @@ +/** + * 페이지 제목 옆 ? 버튼 — 설명 툴팁 + */ +(function initPageHelp() { + /** @type {HTMLElement|null} */ + let activeTooltip = null; + + /** @type {HTMLElement|null} */ + let activeBtn = null; + + function closeTooltip() { + if (activeTooltip) { + activeTooltip.remove(); + activeTooltip = null; + } + if (activeBtn) { + activeBtn.setAttribute('aria-expanded', 'false'); + activeBtn = null; + } + } + + /** + * @param {HTMLElement} btn + * @param {string} text + */ + function openTooltip(btn, text) { + if (!text) return; + closeTooltip(); + + const tip = document.createElement('div'); + tip.className = 'page-help-tooltip'; + tip.setAttribute('role', 'tooltip'); + tip.id = `page-help-tip-${Date.now()}`; + tip.textContent = text; + document.body.appendChild(tip); + btn.setAttribute('aria-expanded', 'true'); + btn.setAttribute('aria-describedby', tip.id); + + const rect = btn.getBoundingClientRect(); + const margin = 12; + let left = rect.left + window.scrollX; + const top = rect.bottom + window.scrollY + 8; + const tipWidth = tip.offsetWidth; + + if (left + tipWidth > window.innerWidth - margin) { + left = window.innerWidth - tipWidth - margin; + } + left = Math.max(margin, left); + + tip.style.top = `${top}px`; + tip.style.left = `${left}px`; + + activeTooltip = tip; + activeBtn = btn; + } + + /** + * @param {object} [translations] + */ + function bindPageHelp(translations) { + const tFn = (key, fallback) => { + if (translations?.[key]) return translations[key]; + if (window.UaPageI18n?.t) return window.UaPageI18n.t(key, fallback); + return fallback; + }; + + document.querySelectorAll('[data-i18n-tooltip]').forEach((btn) => { + const key = btn.dataset.i18nTooltip; + const text = tFn(key, ''); + const helpLabel = tFn('pageHelpLabel', '페이지 안내'); + btn.setAttribute('aria-label', text ? `${helpLabel}` : helpLabel); + + if (btn.dataset.helpBound === '1') return; + btn.dataset.helpBound = '1'; + + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const current = tFn(key, ''); + if (activeBtn === btn && activeTooltip) { + closeTooltip(); + return; + } + openTooltip(btn, current); + }); + }); + } + + document.addEventListener('click', closeTooltip); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeTooltip(); + }); + window.addEventListener('scroll', closeTooltip, { passive: true }); + window.addEventListener('resize', closeTooltip); + + window.UaPageHelp = { bindPageHelp, closeTooltip }; +})(); diff --git a/script/page-i18n.js b/script/page-i18n.js new file mode 100644 index 0000000..6880b42 --- /dev/null +++ b/script/page-i18n.js @@ -0,0 +1,72 @@ +/** + * data-i18n 속성 기반 페이지 번역 + */ +(function initPageI18n() { + /** + * @returns {Promise} + */ + function getLang() { + const fromQuery = new URLSearchParams(window.location.search).get('lang'); + if (fromQuery) return fromQuery; + return localStorage.getItem('lang') || 'ko'; + } + + /** + * @param {string} lang + * @returns {Promise} + */ + async function loadTranslations(lang) { + try { + const res = await fetch(`/i18n/translations.${lang}.json`); + if (!res.ok) return {}; + return await res.json(); + } catch { + return {}; + } + } + + let cachedTranslations = {}; + + /** + * @param {string} key + * @param {string} [fallback] + * @returns {string} + */ + function t(key, fallback = '') { + return cachedTranslations[key] || fallback; + } + + /** + * @param {object} translations + */ + function applyTranslations(translations) { + document.querySelectorAll('[data-i18n]').forEach((el) => { + const key = el.dataset.i18n; + if (translations[key]) el.textContent = translations[key]; + }); + document.querySelectorAll('[data-i18n-html]').forEach((el) => { + const key = el.dataset.i18nHtml; + if (translations[key]) el.innerHTML = translations[key]; + }); + document.querySelectorAll('[data-i18n-placeholder]').forEach((el) => { + const key = el.dataset.i18nPlaceholder; + if (translations[key]) el.placeholder = translations[key]; + }); + if (window.UaPageHelp?.bindPageHelp) { + window.UaPageHelp.bindPageHelp(translations); + } + } + + async function applyPageI18n() { + const lang = await getLang(); + cachedTranslations = await loadTranslations(lang); + applyTranslations(cachedTranslations); + return cachedTranslations; + } + + document.addEventListener('DOMContentLoaded', () => { + applyPageI18n(); + }); + + window.UaPageI18n = { applyPageI18n, getLang, loadTranslations, t }; +})(); diff --git a/signup.html b/signup.html index a0b5db4..df99924 100644 --- a/signup.html +++ b/signup.html @@ -20,33 +20,38 @@
-

회원가입

-

UADECK 계정을 만들고
덱을 저장·공유해 보세요.

+

회원가입

+

UADECK 계정을 만들고
덱을 저장·공유해 보세요.

- +
- +
- - + + +
+
+ +
- +
+