v4.0.0: 덱 UX·i18n·썸네일 저장 개선 및 413 수정
즐겨찾기·홀드 미리보기·페이지 도움말·3개국어 번역을 추가하고, 덱 저장 시 썸네일을 1200px로 통일하며 Nginx/API 업로드 제한을 상향했다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
20
script/ads.js
Normal file
20
script/ads.js
Normal file
@@ -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();
|
||||
}
|
||||
})();
|
||||
@@ -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 서버가 실행 중인지 확인하세요.';
|
||||
}
|
||||
|
||||
@@ -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 `<span class="auth-avatar-wrap">${avatar}</span>`;
|
||||
if (!name) {
|
||||
return `<span class="auth-avatar-circle">${avatar}</span>`;
|
||||
}
|
||||
return `
|
||||
<span class="auth-avatar-wrap">${avatar}</span>
|
||||
<span class="auth-username">${escapeHtml(user.username)}</span>
|
||||
<span class="auth-avatar-circle">${avatar}</span>
|
||||
<span class="auth-username">${escapeHtml(name)}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -86,28 +109,28 @@
|
||||
|
||||
if (!currentUser) {
|
||||
dd.innerHTML = `
|
||||
<a href="/login.html" class="auth-dropdown-item">로그인</a>
|
||||
<a href="/signup.html" class="auth-dropdown-item">회원가입</a>
|
||||
<a href="/login.html" class="auth-dropdown-item">${escapeHtml(t('authLogin', '로그인'))}</a>
|
||||
<a href="/signup.html" class="auth-dropdown-item">${escapeHtml(t('authSignup', '회원가입'))}</a>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const adminLink =
|
||||
currentUser.role === 'admin'
|
||||
? `<a href="/admin.html" class="auth-dropdown-item">관리자</a>`
|
||||
? `<a href="/admin.html" class="auth-dropdown-item">${escapeHtml(t('authAdmin', '관리자'))}</a>`
|
||||
: '';
|
||||
|
||||
dd.innerHTML = `
|
||||
<div class="auth-dropdown-header">
|
||||
<div class="auth-dropdown-name">${currentUser.username}</div>
|
||||
<div class="auth-dropdown-email">${currentUser.email || ''}</div>
|
||||
<div class="auth-dropdown-name">${escapeHtml(getDisplayName(currentUser))}</div>
|
||||
<div class="auth-dropdown-email">${escapeHtml(currentUser.email || '')}</div>
|
||||
</div>
|
||||
<a href="/profile.html" class="auth-dropdown-item">내 프로필</a>
|
||||
<a href="/deckHistory.html" class="auth-dropdown-item">내 덱 관리</a>
|
||||
<a href="/preset.html" class="auth-dropdown-item">공개 덱</a>
|
||||
<a href="/profile.html" class="auth-dropdown-item">${escapeHtml(t('authMyProfile', '내 프로필'))}</a>
|
||||
<a href="/deckHistory.html" class="auth-dropdown-item">${escapeHtml(t('authMyDecks', '내 덱 관리'))}</a>
|
||||
<a href="/preset.html" class="auth-dropdown-item">${escapeHtml(t('authPublicDecks', '공개 덱'))}</a>
|
||||
${adminLink}
|
||||
<div class="auth-dropdown-divider"></div>
|
||||
<button type="button" class="auth-dropdown-item danger" id="auth-logout-btn">로그아웃</button>
|
||||
<button type="button" class="auth-dropdown-item danger" id="auth-logout-btn">${escapeHtml(t('authLogout', '로그아웃'))}</button>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<div class="auth-header-wrap">
|
||||
<button type="button" id="auth-avatar-btn" class="auth-avatar-btn" aria-label="계정 메뉴">
|
||||
<button type="button" id="auth-avatar-btn" class="auth-profile-trigger auth-profile-trigger--guest" aria-label="${escapeHtml(t('authAccountMenu', '계정 메뉴'))}">
|
||||
${profileBtnHtml(currentUser)}
|
||||
</button>
|
||||
<div id="auth-dropdown" class="auth-dropdown"></div>
|
||||
@@ -185,6 +214,10 @@
|
||||
* 초기화
|
||||
*/
|
||||
async function init() {
|
||||
if (window.UaPageI18n?.applyPageI18n) {
|
||||
await window.UaPageI18n.applyPageI18n();
|
||||
}
|
||||
|
||||
currentUser = window.UaDeckApi.getCachedUser();
|
||||
await refreshCurrentUser();
|
||||
|
||||
|
||||
60
script/deck-favorites.js
Normal file
60
script/deck-favorites.js
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 덱 즐겨찾기 (localStorage, share_code 기준)
|
||||
*/
|
||||
(function initDeckFavorites() {
|
||||
const STORAGE_KEY = 'uadeck.deckFavorites';
|
||||
|
||||
/**
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
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<string>} 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 };
|
||||
})();
|
||||
179
script/deck-preview-hold.js
Normal file
179
script/deck-preview-hold.js
Normal file
@@ -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 =
|
||||
'<div class="deck-preview-hold-box"><img alt="" draggable="false" /></div>';
|
||||
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 };
|
||||
})();
|
||||
96
script/page-help.js
Normal file
96
script/page-help.js
Normal file
@@ -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 };
|
||||
})();
|
||||
72
script/page-i18n.js
Normal file
72
script/page-i18n.js
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* data-i18n 속성 기반 페이지 번역
|
||||
*/
|
||||
(function initPageI18n() {
|
||||
/**
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
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<object>}
|
||||
*/
|
||||
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 };
|
||||
})();
|
||||
Reference in New Issue
Block a user