v4.0.0: 덱 UX·i18n·썸네일 저장 개선 및 413 수정

즐겨찾기·홀드 미리보기·페이지 도움말·3개국어 번역을 추가하고, 덱 저장 시 썸네일을 1200px로 통일하며 Nginx/API 업로드 제한을 상향했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-22 18:50:08 +09:00
parent dbf02241e2
commit 2ecf6201c0
29 changed files with 2195 additions and 326 deletions

View File

@@ -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: '서버 오류가 발생했습니다.' });
});

View File

@@ -83,7 +83,7 @@
</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 type="button" onclick="goToHome()" class="font-bold text-slate-600 hover:text-blue-600 cursor-pointer">← 홈</button>
<button type="button" id="i18n-home" onclick="goToHome()" class="font-bold text-slate-600 hover:text-blue-600 cursor-pointer">← 홈</button>
<div class="font-bold text-slate-800">
<span id="i18n-searchResults">검색결과</span>
:
@@ -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() {

View File

@@ -26,41 +26,95 @@
<div class="app-body-layout">
<main class="app-main deck-history-main">
<header class="deck-history-header">
<h1>내 덱 관리</h1>
<p>저장한 덱 목록입니다. 공개 전환 시「공개 덱」목록에 표시됩니다.</p>
<div class="page-title-row">
<h1 data-i18n="deckHistoryTitle">내 덱 관리</h1>
<button type="button" class="page-help-btn" data-i18n-tooltip="deckHistoryDesc" aria-expanded="false">?</button>
</div>
</header>
<div id="deck-toolbar" class="deck-history-toolbar hidden">
<input
type="search"
id="deck-search"
class="deck-search-input"
placeholder="덱 이름 또는 시리즈 검색..."
autocomplete="off"
/>
<select id="deck-series-filter" class="deck-series-select" aria-label="시리즈 필터">
<option value="">모든 시리즈</option>
</select>
<span id="deck-count" class="deck-count-label"></span>
<div class="deck-toolbar-fav-row">
<button type="button" id="deck-favorite-filter" class="deck-favorite-filter" aria-pressed="false">
<span class="deck-favorite-filter-icon deck-star-icon" aria-hidden="true"></span>
<span data-i18n="deckFavoriteFilterOnly">즐겨찾기만 보기</span>
<span id="deck-fav-count" class="deck-fav-count"></span>
</button>
</div>
<div class="deck-toolbar-main-row">
<input
type="search"
id="deck-search"
class="deck-search-input"
data-i18n-placeholder="deckSearchPlaceholder"
placeholder="덱 이름 또는 시리즈 검색..."
autocomplete="off"
/>
<select id="deck-series-filter" class="deck-series-select" aria-label="시리즈 필터">
<option value="">모든 시리즈</option>
</select>
<span id="deck-count" class="deck-count-label"></span>
</div>
</div>
<div id="deck-list">
<div class="deck-status-message">불러오는 중...</div>
<div class="deck-status-message" data-i18n="deckLoading">불러오는 중...</div>
</div>
</main>
<aside class="app-ad-rail" aria-label="광고">
<div class="app-ad-slot app-ad-slot--skyscraper" id="ad-sidebar"></div>
<div class="app-ad-slot app-ad-slot--skyscraper" id="ad-sidebar">
<ins
class="adsbygoogle"
style="display: block"
data-ad-client="ca-pub-4516420168710424"
data-ad-slot="4260332296"
data-ad-format="auto"
data-full-width-responsive="true"
></ins>
</div>
</aside>
</div>
</div>
<script src="/script/api.js"></script>
<script src="/script/ads.js"></script>
<script src="/script/deck-favorites.js"></script>
<script src="/script/deck-preview-hold.js"></script>
<script src="/script/page-i18n.js"></script>
<script src="/script/page-help.js"></script>
<script src="/script/auth-header.js"></script>
<script>
/** @type {Array<object>} */
let allDecks = [];
/**
* @param {string} key
* @param {string} fallback
* @returns {string}
*/
function t(key, fallback) {
return window.UaPageI18n?.t(key, fallback) || fallback;
}
/**
* @param {number} filtered
* @param {number} total
* @returns {string}
*/
function formatDeckCount(filtered, total) {
return t('deckCountFormat', '{filtered} / {total}개')
.replace('{filtered}', String(filtered))
.replace('{total}', String(total));
}
/**
* 미리보기 없음 플레이스홀더 HTML
* @returns {string}
*/
function noPreviewHtml() {
return `<div class="deck-card-preview-placeholder">${escapeHtml(t('deckNoPreview', '미리보기 없음'))}<br><span>${escapeHtml(t('deckResaveHint', 'Preview에서 다시 저장'))}</span></div>`;
}
/**
* HTML 이스케이프
* @param {string} str
@@ -80,7 +134,7 @@
* @returns {string}
*/
function getDeckTitle(deck) {
return deck.name || deck.series_name || deck.series || '이름 없음';
return deck.name || deck.series_name || deck.series || t('deckUntitled', '이름 없음');
}
/**
@@ -103,7 +157,7 @@
);
select.innerHTML =
'<option value="">모든 시리즈</option>' +
`<option value="">${escapeHtml(t('deckAllSeries', '모든 시리즈'))}</option>` +
sorted
.map(
([key, label]) =>
@@ -123,8 +177,11 @@
function getFilteredDecks() {
const query = document.getElementById('deck-search').value.trim().toLowerCase();
const series = document.getElementById('deck-series-filter').value;
const favOnly =
document.getElementById('deck-favorite-filter')?.getAttribute('aria-pressed') === 'true';
return allDecks.filter((d) => {
if (favOnly && !UaDeckFavorites.isFavorite(d.share_code)) return false;
if (series && d.series !== series) return false;
if (!query) return true;
@@ -142,6 +199,43 @@
});
}
/**
* 즐겨찾기 개수
* @returns {number}
*/
function countFavorites() {
return allDecks.filter((d) => UaDeckFavorites.isFavorite(d.share_code)).length;
}
/**
* 즐겨찾기 개수 라벨 갱신
*/
function updateFavCount() {
const el = document.getElementById('deck-fav-count');
if (!el) return;
const n = countFavorites();
el.textContent = n > 0 ? String(n) : '';
}
/**
* 즐겨찾기 버튼 HTML
* @param {string} shareCode
* @returns {string}
*/
function favoriteButtonHtml(shareCode) {
const on = UaDeckFavorites.isFavorite(shareCode);
const cls = on ? 'deck-card-btn-favorite is-on' : 'deck-card-btn-favorite';
const starCls = on ? 'deck-star-icon is-on' : 'deck-star-icon';
return `<button type="button" class="deck-card-btn ${cls}" data-favorite="${escapeHtml(shareCode)}" aria-pressed="${on ? 'true' : 'false'}" aria-label="${escapeHtml(t('deckFavorite', '즐겨찾기'))}"><span class="${starCls}" aria-hidden="true"></span>${escapeHtml(t('deckFavorite', '즐겨찾기'))}</button>`;
}
function getPreviewSrc(d) {
if (!d.preview_url) return null;
const src = UaDeckApi.resolveMediaUrl(d.preview_url);
const version = d.updated_at ? `?v=${encodeURIComponent(d.updated_at)}` : '';
return src + version;
}
/**
* 미리보기 HTML
* @param {object} d
@@ -149,12 +243,12 @@
* @returns {string}
*/
function renderPreviewHtml(d, title) {
if (!d.preview_url) {
return '<div class="deck-card-preview-placeholder">미리보기 없음<br><span>Preview에서 다시 저장</span></div>';
const previewSrc = getPreviewSrc(d);
if (!previewSrc) {
return `<div class="deck-card-preview"><div class="deck-card-preview-placeholder">${escapeHtml(t('deckNoPreview', '미리보기 없음'))}<br><span>${escapeHtml(t('deckResaveHint', 'Preview에서 다시 저장'))}</span></div></div>`;
}
const src = UaDeckApi.resolveMediaUrl(d.preview_url);
const version = d.updated_at ? `?v=${encodeURIComponent(d.updated_at)}` : '';
return `<img src="${escapeHtml(src + version)}" alt="${title} 미리보기" loading="lazy" class="deck-card-preview-img" />`;
const holdAttrs = `data-hold-preview data-preview-src="${escapeHtml(previewSrc)}"`;
return `<div class="deck-card-preview deck-card-preview--holdable" ${holdAttrs}><img src="${escapeHtml(previewSrc)}" alt="${escapeHtml(title)} 미리보기" loading="lazy" class="deck-card-preview-img" draggable="false" /></div>`;
}
/**
@@ -166,36 +260,55 @@
const title = escapeHtml(getDeckTitle(d));
const seriesLabel = escapeHtml(d.series_name || d.series);
const date = new Date(d.updated_at).toLocaleDateString('ko-KR');
const preview = renderPreviewHtml(d, title);
const isPublic = Boolean(d.is_public);
const visibilityBadge = isPublic
? '<span class="deck-card-badge-public">공개</span>'
: '<span class="deck-card-badge-private">비공개</span>';
const toggleLabel = isPublic ? '비공개 전환' : '공개 전환';
const toggleClass = isPublic ? 'deck-card-btn-toggle is-public' : 'deck-card-btn-toggle';
? `<span class="deck-card-badge-public">${escapeHtml(t('deckBadgePublic', '공개'))}</span>`
: `<span class="deck-card-badge-private">${escapeHtml(t('deckBadgePrivate', '비공개'))}</span>`;
const toggleLabel = isPublic
? t('deckMakePrivate', '비공개 전환')
: t('deckMakePublic', '공개 전환');
const favClass = UaDeckFavorites.isFavorite(d.share_code) ? 'deck-card is-favorite' : 'deck-card';
return `
<article class="deck-card" data-code="${escapeHtml(d.share_code)}">
<div class="deck-card-preview">${preview}</div>
<article class="${favClass}" data-code="${escapeHtml(d.share_code)}">
${renderPreviewHtml(d, title)}
<div class="deck-card-body">
<div class="deck-card-visibility">
<span class="deck-card-series">${seriesLabel}</span>
${visibilityBadge}
</div>
<h2 class="deck-card-title">${title}</h2>
<div class="deck-card-meta">조회 ${d.view_count} · ${date}</div>
<div class="deck-card-actions">
<button type="button" class="deck-card-btn deck-card-btn-load" data-load="${escapeHtml(d.share_code)}" data-series="${escapeHtml(d.series)}" data-name="${escapeHtml(d.series_name || '')}">불러오기</button>
<button type="button" class="deck-card-btn ${toggleClass}" data-toggle="${escapeHtml(d.share_code)}" data-public="${isPublic ? '1' : '0'}">${toggleLabel}</button>
</div>
<div class="deck-card-actions">
<button type="button" class="deck-card-btn deck-card-btn-copy" data-copy="${escapeHtml(d.share_code)}">링크 복사</button>
<button type="button" class="deck-card-btn deck-card-btn-delete" data-delete="${escapeHtml(d.share_code)}" title="삭제" aria-label="삭제">삭제</button>
<div class="deck-card-meta">${escapeHtml(t('deckViews', '조회'))} ${d.view_count} · ${date}</div>
<div class="deck-card-actions deck-card-actions--primary">
${favoriteButtonHtml(d.share_code)}
<button type="button" class="deck-card-btn deck-card-btn-load" data-load="${escapeHtml(d.share_code)}" data-series="${escapeHtml(d.series)}" data-name="${escapeHtml(d.series_name || '')}">${escapeHtml(t('deckLoad', '불러오기'))}</button>
<div class="deck-card-more-wrap">
<button type="button" class="deck-card-more-btn" aria-haspopup="true" aria-expanded="false" aria-label="${escapeHtml(t('deckMoreActions', '더 보기'))}">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><circle cx="8" cy="3" r="1.5"/><circle cx="8" cy="8" r="1.5"/><circle cx="8" cy="13" r="1.5"/></svg>
</button>
<div class="deck-card-more-menu hidden" role="menu">
<button type="button" class="deck-card-more-item" role="menuitem" data-copy="${escapeHtml(d.share_code)}">${escapeHtml(t('deckCopyLink', '링크 복사'))}</button>
<button type="button" class="deck-card-more-item" role="menuitem" data-toggle="${escapeHtml(d.share_code)}" data-public="${isPublic ? '1' : '0'}">${escapeHtml(toggleLabel)}</button>
<button type="button" class="deck-card-more-item deck-card-more-item--danger" role="menuitem" data-delete="${escapeHtml(d.share_code)}">${escapeHtml(t('deckDelete', '삭제'))}</button>
</div>
</div>
</div>
</div>
</article>`;
}
/**
* 열린 더보기 메뉴 닫기
*/
function closeAllDeckMenus() {
document.querySelectorAll('.deck-card-more-menu').forEach((menu) => {
menu.classList.add('hidden');
});
document.querySelectorAll('.deck-card-more-btn').forEach((btn) => {
btn.setAttribute('aria-expanded', 'false');
});
}
/**
* 목록 렌더링
*/
@@ -204,22 +317,32 @@
const countEl = document.getElementById('deck-count');
const filtered = getFilteredDecks();
countEl.textContent = `${filtered.length} / ${allDecks.length}개`;
countEl.textContent = formatDeckCount(filtered.length, allDecks.length);
updateFavCount();
if (!filtered.length) {
const favOnly =
document.getElementById('deck-favorite-filter')?.getAttribute('aria-pressed') === 'true';
const emptyMsg = favOnly
? t('deckNoFavorites', '즐겨찾기한 덱이 없습니다.')
: t('deckNoMatch', '조건에 맞는 덱이 없습니다.');
list.innerHTML =
'<div class="deck-grid"><div class="deck-grid-empty">조건에 맞는 덱이 없습니다.</div></div>';
`<div class="deck-grid"><div class="deck-grid-empty">${escapeHtml(emptyMsg)}</div></div>`;
return;
}
list.innerHTML = `<div class="deck-grid">${filtered.map(renderDeckCard).join('')}</div>`;
closeAllDeckMenus();
UaDeckPreviewHold.bindHoldPreview(list);
list.querySelectorAll('.deck-card-preview-img').forEach((img) => {
img.addEventListener('error', () => {
const wrap = img.closest('.deck-card-preview');
if (wrap) {
wrap.innerHTML =
'<div class="deck-card-preview-placeholder">미리보기 없음<br><span>Preview에서 다시 저장</span></div>';
wrap.classList.remove('deck-card-preview--holdable');
wrap.removeAttribute('data-hold-preview');
wrap.removeAttribute('data-preview-src');
wrap.innerHTML = noPreviewHtml();
}
});
});
@@ -244,7 +367,7 @@
if (!decks.length) {
toolbar.classList.add('hidden');
list.innerHTML =
'<div class="deck-status-message">저장된 덱이 없습니다.</div>';
`<div class="deck-status-message">${escapeHtml(t('deckEmpty', '저장된 덱이 없습니다.'))}</div>`;
return;
}
@@ -257,13 +380,49 @@
}
}
document.addEventListener('DOMContentLoaded', () => {
document.addEventListener('DOMContentLoaded', async () => {
if (window.UaPageI18n?.applyPageI18n) {
await window.UaPageI18n.applyPageI18n();
}
loadDecks();
document.getElementById('deck-search').addEventListener('input', renderDeckList);
document.getElementById('deck-series-filter').addEventListener('change', renderDeckList);
const favFilterBtn = document.getElementById('deck-favorite-filter');
const favFilterIcon = favFilterBtn.querySelector('.deck-star-icon');
favFilterBtn.addEventListener('click', () => {
const on = favFilterBtn.getAttribute('aria-pressed') !== 'true';
favFilterBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
favFilterIcon.classList.toggle('is-on', on);
renderDeckList();
});
document.getElementById('deck-list').addEventListener('click', async (e) => {
const moreBtn = e.target.closest('.deck-card-more-btn');
if (moreBtn) {
e.stopPropagation();
const menu = moreBtn.parentElement?.querySelector('.deck-card-more-menu');
const isOpen = menu && !menu.classList.contains('hidden');
closeAllDeckMenus();
if (!isOpen && menu) {
menu.classList.remove('hidden');
moreBtn.setAttribute('aria-expanded', 'true');
}
return;
}
if (!e.target.closest('.deck-card-more-wrap')) {
closeAllDeckMenus();
}
const favBtn = e.target.closest('[data-favorite]');
if (favBtn) {
UaDeckFavorites.toggle(favBtn.dataset.favorite);
renderDeckList();
return;
}
const loadBtn = e.target.closest('[data-load]');
if (loadBtn) {
const code = loadBtn.dataset.load;
@@ -277,7 +436,8 @@
if (copyBtn) {
const url = `${location.origin}/d/${copyBtn.dataset.copy}`;
await navigator.clipboard.writeText(url);
alert('공유 링크가 복사되었습니다.');
closeAllDeckMenus();
alert(t('deckLinkCopied', '공유 링크가 복사되었습니다.'));
return;
}
@@ -285,24 +445,28 @@
if (toggleBtn) {
const code = toggleBtn.dataset.toggle;
const nextPublic = toggleBtn.dataset.public !== '1';
const label = nextPublic ? '이 덱을 공개 덱 목록에 표시합니다. 계속할까요?' : '비공개로 전환합니다. 공개 목록에서 숨겨집니다.';
const label = nextPublic
? t('deckMakePublicConfirm', '이 덱을 공개 덱 목록에 표시합니다. 계속할까요?')
: t('deckMakePrivateConfirm', '비공개로 전환합니다. 공개 목록에서 숨겨집니다.');
if (!confirm(label)) return;
await UaDeckApi.updateDeck(code, { isPublic: nextPublic });
const deck = allDecks.find((d) => d.share_code === code);
if (deck) deck.is_public = nextPublic ? 1 : 0;
closeAllDeckMenus();
renderDeckList();
return;
}
const deleteBtn = e.target.closest('[data-delete]');
if (deleteBtn) {
if (!confirm('이 덱을 삭제하시겠습니까?')) return;
closeAllDeckMenus();
if (!confirm(t('deckDeleteConfirm', '이 덱을 삭제하시겠습니까?'))) return;
await UaDeckApi.deleteDeck(deleteBtn.dataset.delete);
allDecks = allDecks.filter((d) => d.share_code !== deleteBtn.dataset.delete);
if (!allDecks.length) {
document.getElementById('deck-toolbar').classList.add('hidden');
document.getElementById('deck-list').innerHTML =
'<div class="deck-status-message">저장된 덱이 없습니다.</div>';
`<div class="deck-status-message">${escapeHtml(t('deckEmpty', '저장된 덱이 없습니다.'))}</div>`;
return;
}
populateSeriesFilter(allDecks);

View File

@@ -24,19 +24,23 @@
## `/login.html`, `/signup.html`
- 인증 화면
- 연결: `script/page-i18n.js` (`data-i18n` 속성)
## `/profile.html` (내 프로필)
- 로그인 사용자: 이메일·사용자명·프로필 사진·비밀번호 변경
- 연결: `script/api.js` (`updateProfile`, `changePassword`), `script/image-utils.js`, `style/profile.css`
- 연결: `script/api.js` (`updateProfile`, `changePassword`), `script/image-utils.js`, `script/page-i18n.js`, `script/ads.js`, `style/profile.css`
## `/deckHistory.html`
- 로그인 사용자의 저장 덱 목록·불러오기·삭제
- 카드 그리드 + 미리보기 썸네일 (`preview_url`)
- 카드 그리드 + 미리보기 썸네일 (`preview_url`), 클릭·길게 눌러 확대 (`deck-preview-hold.js`)
- 시리즈 필터·검색 (`style/deck-history.css`)
- 연결: `script/page-i18n.js`, `script/ads.js` (우측 AdSense `4260332296`)
## `/preset.html` (공개 덱)
- 사용자가 공개 전환한 덱 목록 (카드 그리드)
- 썸네일 클릭·길게 눌러 확대 (`deck-preview-hold.js`, 안내 문구 없음)
- API: `GET /api/v1/decks/presets` → `is_public=1` user 덱
- 연결: `script/page-i18n.js`, `script/ads.js`
## `/admin.html`
- 관리자: 회원 관리, 덱 관리 (`style/admin.css`)
@@ -47,7 +51,12 @@
## 공통
- `style/app-shell.css`: 헤더·본문 최대 너비 1280px (`max-w-7xl`), 데스크톱 우측 광고 레일
- `script/ads.js`: Google AdSense `ins.adsbygoogle` 슬롯 초기화
- `script/page-help.js`: 페이지 제목 `?` 툴팁
- `script/page-i18n.js`: `data-i18n` / `data-i18n-html` / `data-i18n-placeholder`, `UaPageI18n.t()`
- `script/deck-favorites.js`: 덱 즐겨찾기 (localStorage)
- `script/deck-preview-hold.js`: 덱 썸네일 클릭·길게 눌러 확대 (내 덱·공개 덱)
- `script/api.js`: API 클라이언트 (`updateProfile`, `changePassword` 포함)
- `script/auth-header.js`: 헤더 프로필 드롭다운 (내 프로필, 내 덱 관리, 공개 덱), 로그인 시 아바타 옆 사용자명 표시
- `script/auth-header.js`: 헤더 프로필 드롭다운 (i18n), 로그인 시 **썸네일 옆 닉네임** 표시 (`auth-profile-trigger`)
- `script/image-utils.js`: 클라이언트 이미지 PNG 리사이즈 (`UaImageUtils.resizeImageToPng`)
- `style/auth-header.css`

View File

@@ -21,7 +21,11 @@
- [ ] `.env` 설정 및 기존 리버스 프록시 연동
- [ ] 옆 프로젝트에 `integrations/uadeck-deck-client.js` 적용
- [ ] `cardList.html` 인라인 스크립트 모듈 분리
- [ ] i18n 번역 키 추가 (auth, admin 문구)
- [x] i18n: 모달·홈 버튼·auth·덱 관리·프로필·로그인/가입 3개국어 (v4.0.0 후속)
- [x] 덱 즐겨찾기·필터 (내 덱·공개 덱)
- [x] 공개 덱: 홀드 미리보기 + 하단 즐겨찾기/불러오기 버튼
- [x] 내 덱: 썸네일 클릭·홀드 확대, 저장 썸네일 1200px 정규화
- [ ] i18n: `admin.html` 문구
## v4.0.0 최근 완료
- [x] `profile.html`: 일반 사용자 프로필 화면 (아바타·닉네임·이메일·비밀번호)

View File

@@ -1,5 +1,85 @@
# 업데이트 이력
## 2026-06-19 / v4.0.0 — 덱 썸네일 미리보기·저장 크기 통일
### 수행 작업
- `deckHistory.html`: `deck-preview-hold.js` 연동 — 내 덱 썸네일도 클릭·길게 눌러 확대
- `preset.html`: 썸네일 위「클릭·길게 눌러…」안내 문구 제거 (홀드 기능 유지)
- `result.html`: 저장용 미리보기를 다운로드와 동일한 **1200px** 기준으로 정규화 (`normalizePreviewForSave`)
- 저장 시 HQ 옵션 무시 (`highQuality: false`) 후 너비 1200px로 스케일
- 기존 960px 축소만 하던 `compressPreviewForSave` 제거 → 크기 제각각이던 원인 해소
- `style/deck-history.css`: 미사용 `.deck-card-preview-hint` 제거
### 참고
- 이미 저장된 덱 썸네일은 예전 크기 그대로이며, Preview에서 **다시 저장**하면 1200px로 갱신됨
## 2026-06-19 / v4.0.0 — 덱 저장 413 수정
### 원인
- `localhost:8080` 요청은 Nginx(`client_max_body_size 2m`)를 거침
- 덱 저장 시 미리보기 PNG(base64)가 JSON에 포함되어 2MB 초과 → **413 Request Entity Too Large**
### 수행 작업
- `nginx/default.conf`: `client_max_body_size` 2m → **25m** (API location 포함)
- `api/src/index.js`: JSON limit 20mb, 413 전용 오류 메시지
- `script/api.js`: 413 사용자 안내 문구
- `result.html`: 저장용 미리보기 최대 너비 960px 리사이즈
### 적용
```bash
npm stop && npm start
```
## 2026-06-19 / v4.0.0 — 공개 덱 미리보기 수정
### 수행 작업
- `deck-preview-hold.js`: mouseleave 즉시 닫힘 버그 수정, 클릭 고정·길게 누르기 동시 지원
- ~~안내 문구:「클릭·길게 눌러 크게 보기」~~ → 이후 제거됨
## 2026-06-19 / v4.0.0 — 프로필 덱 저장 설정 UI
### 수행 작업
- 덱 저장 설정: 설명 문단 제거 → `?` 툴팁, 설정 패널·토글 ON/OFF 표시·즉시 저장 안내 메시지
- 토글 클릭 시 API/localStorage 동기화는 기존 `deck-prefs.js` 유지
## 2026-06-19 / v4.0.0 — 메인 프로필 i18n·페이지 도움말
### 수행 작업
- `index.html`: `page-i18n.js` 로드, 언어 전환 시 `UaPageI18n` + `UaDeckAuth.refresh()`로 프로필 드롭다운 재번역
- `script/page-help.js`: 제목 옆 `?` 버튼 클릭 시 설명 툴팁
- `deckHistory.html`, `preset.html`, `profile.html`: 상단 설명 `<p>` 제거 → `?` 도움말 버튼
- `style/app-shell.css`: `.page-title-row`, `.page-help-btn`, `.page-help-tooltip`
## 2026-06-19 / v4.0.0 — 덱 즐겨찾기·공개 덱 미리보기
### 수행 작업
- `script/deck-favorites.js`: share_code 기준 즐겨찾기 (localStorage)
- `script/deck-preview-hold.js`: 썸네일 길게 누르기 확대 미리보기
- `deckHistory.html`: 카드 하단 즐겨찾기 버튼, 툴바「즐겨찾기」필터
- `preset.html`: 카드 클릭 이동 제거 → 홀드 미리보기 + 하단「즐겨찾기」「불러오기」버튼
- `style/deck-history.css`: 즐겨찾기·홀드 오버레이 스타일
- i18n: `deckFavorite`, `deckFavoriteFilter`, `deckHoldPreviewHint`
### 메모
- 즐겨찾기는 브라우저 localStorage에 저장 (기기·브라우저별). 추후 계정 동기화 API 확장 가능.
## 2026-06-19 / v4.0.0 — AdSense·i18n 보완
### 수행 작업
- Google AdSense 우측 슬롯(`4260332296`) 삽입: `deckHistory.html`, `preset.html`, `profile.html` `#ad-sidebar`
- `script/ads.js`: `ins.adsbygoogle` 초기화 헬퍼
- `script/page-i18n.js`: `data-i18n` / `data-i18n-html` / `data-i18n-placeholder` + `UaPageI18n.t()` 헬퍼
- `script/auth-header.js`: 드롭다운 메뉴 3개국어(ko/en/ja)
- `login.html`, `signup.html`, `profile.html`, `deckHistory.html`, `preset.html` 정적 문구 i18n 연결
- `cardList.html`: `← 홈` 버튼 `homeNav` 키 적용
- `result.html`: 저장 모달·로딩 확인·홈 버튼 기존 `applyTranslationsToUI` 유지, `page-i18n`으로 헤더 메뉴 번역
- `deckHistory.html` / `preset.html`: 목록·버튼·확인 대화상자 동적 문구 i18n
- `i18n/translations.{ko,en,ja}.json`: auth·덱 관리·프로필·공개 덱 관련 키 추가
### 확인
- `?lang=en` 또는 `?lang=ja` 로 각 페이지 문구 전환 확인
- AdSense는 로컬/비승인 도메인에서는 빈 슬롯으로 보일 수 있음
## 2026-06-19 / v4.0.0
### 수행 작업
@@ -92,6 +172,13 @@
- 프로필 폼은 콘텐츠 영역 내 `max-width: 42rem` 유지
- `auth-pages.css`, `admin.css` 너비도 80rem으로 통일
### 후속 UI
- `result.html` 저장 모달 토글: Tailwind `translate-x-6` 미빌드 → 전용 `.save-deck-switch` CSS
- `result.html` 하단 버튼 비율: 홈 0.5 · 다운로드 0.5 · 내 덱 저장 2
- `result.html` 덱 저장: 브라우저 alert 제거 → 로딩 모달 완료 메시지 후 홈 이동
- `signup.html`: 비밀번호 확인 필드, 가입 후 홈(`/`) 이동
- `cardList.html`: 동일 카드 번호(패러랠 포함) 수량 공유 시 +/- 버튼 UI 동기화
## 2026-06-19 / v4.0.0 — 덱 썸네일 수정
### 수행 작업

View File

@@ -44,6 +44,106 @@
"back": "BACK",
"saveError": "Failed to save image",
"goHome": "Home",
"downloadImageLabel": "Download Image",
"saveToMyDeckLabel": "Save to My Deck",
"savePublicTitle": "Make this deck public?",
"savePublicDesc": "Public decks appear in the Public Decks list.",
"savePublicLabel": "Public",
"savePublicDefault": "Default: Yes",
"saveSkipLabel": "Don't ask again",
"saveSkipHint": "Your choice becomes the default",
"saveButton": "Save",
"okButton": "OK",
"deckSavedPublic": "Deck saved as public.",
"deckSavedPrivate": "Deck saved as private.",
"previewFailedNote": "(Preview image could not be generated)",
"redirectingHome": "Redirecting to home...",
"saveFailedTitle": "Save failed.",
"tryAgain": "Please try again.",
"noCardsToSave": "No cards to save.",
"homeNav": "← Home",
"authLogin": "Log in",
"authSignup": "Sign up",
"authMyProfile": "My Profile",
"authMyDecks": "My Decks",
"authPublicDecks": "Public Decks",
"authAdmin": "Admin",
"authLogout": "Log out",
"authAccountMenu": "Account menu",
"authAccountMenuNamed": "{name} account menu",
"loginPageTitle": "Log in",
"loginPageDesc": "Sign in to save and manage<br />your decks.",
"signupPageTitle": "Sign up",
"signupPageDesc": "Create a UADECK account to<br />save and share decks.",
"emailLabel": "Email",
"passwordLabel": "Password",
"usernameLabel": "Username",
"passwordConfirmLabel": "Confirm password",
"passwordConfirmPlaceholder": "Re-enter password",
"signupPasswordPlaceholder": "8+ characters",
"passwordMismatch": "Passwords do not match.",
"signupButton": "Create account",
"loginButton": "Log in",
"noAccount": "Don't have an account?",
"hasAccount": "Already have an account?",
"deckHistoryTitle": "My Decks",
"deckHistoryDesc": "Your saved decks. Public decks appear in the Public Decks list.",
"publicDecksTitle": "Public Decks",
"publicDecksDesc": "Decks marked public by users. Toggle visibility in My Decks.",
"profileTitle": "My Profile",
"profileDesc": "Update your photo, username, email, and password.",
"profileSave": "Save profile",
"profileDeckSettings": "Deck save settings",
"profileDeckSettingsDesc": "Default visibility when saving from the result screen.",
"profileDefaultPublic": "Public by default",
"profileDefaultPublicHint": "New saves default to public",
"profileAskOnSave": "Ask visibility on save",
"profileAskOnSaveHint": "Off uses your default without asking",
"profileDeckPrefsSaved": "Deck save settings applied.",
"profileSettingOn": "ON",
"profileSettingOff": "OFF",
"changePassword": "Change password",
"changePasswordButton": "Update",
"newPasswordLabel": "New password",
"confirmPasswordLabel": "Confirm new password",
"loadingLabel": "Loading...",
"deckSearchPlaceholder": "Search deck name or series...",
"deckAllSeries": "All series",
"deckLoading": "Loading...",
"deckEmpty": "No saved decks yet.",
"deckNoMatch": "No decks match your filters.",
"deckUntitled": "Untitled",
"deckNoPreview": "No preview",
"deckResaveHint": "Re-save from Preview",
"deckBadgePublic": "Public",
"deckBadgePrivate": "Private",
"deckMakePublic": "Make public",
"deckMakePrivate": "Make private",
"deckLoad": "Load",
"deckCopyLink": "Copy link",
"deckDelete": "Delete",
"deckViews": "Views",
"deckCountFormat": "{filtered} / {total}",
"publicDeckEmpty": "No public decks yet.",
"publicDeckSearchPlaceholder": "Search deck, series, or author...",
"deckLinkCopied": "Share link copied.",
"deckMakePublicConfirm": "Show this deck in the public list?",
"deckMakePrivateConfirm": "Make this deck private? It will be hidden from the public list.",
"deckDeleteConfirm": "Delete this deck?",
"deckFavorite": "Favorite",
"deckFavoriteFilter": "Favorites",
"deckFavoriteFilterOnly": "Favorites only",
"deckMoreActions": "More actions",
"deckNoFavorites": "No favorite decks yet.",
"deckHoldPreviewHint": "Click or hold to enlarge",
"pageHelpLabel": "Page help",
"all": "All",
"metaDeck": "Public Decks",
"titlePrompt": "Please select a title",

View File

@@ -44,6 +44,106 @@
"back": "戻る",
"saveError": "画像の保存に失敗しました",
"goHome": "ホームへ",
"downloadImageLabel": "画像をダウンロード",
"saveToMyDeckLabel": "マイデックに保存",
"savePublicTitle": "全体に公開しますか?",
"savePublicDesc": "公開すると「公開デッキ」一覧に表示されます。",
"savePublicLabel": "公開",
"savePublicDefault": "初期値: はい",
"saveSkipLabel": "次回から表示しない",
"saveSkipHint": "選択した公開設定が初期値になります",
"saveButton": "保存",
"okButton": "確認",
"deckSavedPublic": "デッキを公開で保存しました。",
"deckSavedPrivate": "デッキを非公開で保存しました。",
"previewFailedNote": "(プレビュー画像の生成に失敗しました)",
"redirectingHome": "まもなくホームへ移動します。",
"saveFailedTitle": "保存に失敗しました。",
"tryAgain": "もう一度お試しください。",
"noCardsToSave": "保存するカードがありません。",
"homeNav": "← ホーム",
"authLogin": "ログイン",
"authSignup": "会員登録",
"authMyProfile": "マイプロフィール",
"authMyDecks": "マイデック管理",
"authPublicDecks": "公開デッキ",
"authAdmin": "管理者",
"authLogout": "ログアウト",
"authAccountMenu": "アカウントメニュー",
"authAccountMenuNamed": "{name} アカウントメニュー",
"loginPageTitle": "ログイン",
"loginPageDesc": "デッキを保存・管理するには<br />アカウントでログインしてください。",
"signupPageTitle": "会員登録",
"signupPageDesc": "UADECKアカウントを作成して<br />デッキを保存・共有しましょう。",
"emailLabel": "メール",
"passwordLabel": "パスワード",
"usernameLabel": "ユーザー名",
"passwordConfirmLabel": "パスワード確認",
"passwordConfirmPlaceholder": "パスワード再入力",
"signupPasswordPlaceholder": "8文字以上",
"passwordMismatch": "パスワードが一致しません。",
"signupButton": "登録する",
"loginButton": "ログイン",
"noAccount": "アカウントをお持ちでない方",
"hasAccount": "すでにアカウントをお持ちの方",
"deckHistoryTitle": "マイデック管理",
"deckHistoryDesc": "保存したデッキ一覧です。公開にすると「公開デッキ」に表示されます。",
"publicDecksTitle": "公開デッキ",
"publicDecksDesc": "ユーザーが公開したデッキ一覧です。マイデック管理で「公開切替」できます。",
"profileTitle": "マイプロフィール",
"profileDesc": "プロフィール写真・ユーザー名・メール・パスワードを変更できます。",
"profileSave": "プロフィール保存",
"profileDeckSettings": "デッキ保存設定",
"profileDeckSettingsDesc": "「マイデックに保存」時の公開設定の初期値です。",
"profileDefaultPublic": "デフォルト公開",
"profileDefaultPublicHint": "保存時に公開デッキへ表示",
"profileAskOnSave": "保存時に公開を確認",
"profileAskOnSaveHint": "オフにすると初期値で即保存",
"profileDeckPrefsSaved": "デッキ保存設定を反映しました。",
"profileSettingOn": "ON",
"profileSettingOff": "OFF",
"changePassword": "パスワード変更",
"changePasswordButton": "変更",
"newPasswordLabel": "新しいパスワード",
"confirmPasswordLabel": "新しいパスワード確認",
"loadingLabel": "読み込み中...",
"deckSearchPlaceholder": "デッキ名またはシリーズを検索...",
"deckAllSeries": "すべてのシリーズ",
"deckLoading": "読み込み中...",
"deckEmpty": "保存されたデッキがありません。",
"deckNoMatch": "条件に合うデッキがありません。",
"deckUntitled": "名称なし",
"deckNoPreview": "プレビューなし",
"deckResaveHint": "プレビュー画面から再保存",
"deckBadgePublic": "公開",
"deckBadgePrivate": "非公開",
"deckMakePublic": "公開にする",
"deckMakePrivate": "非公開にする",
"deckLoad": "読み込み",
"deckCopyLink": "リンクをコピー",
"deckDelete": "削除",
"deckViews": "閲覧",
"deckCountFormat": "{filtered} / {total}件",
"publicDeckEmpty": "公開デッキがありません。",
"publicDeckSearchPlaceholder": "デッキ名・シリーズ・作者を検索...",
"deckLinkCopied": "共有リンクをコピーしました。",
"deckMakePublicConfirm": "このデッキを公開リストに表示しますか?",
"deckMakePrivateConfirm": "非公開にします。公開リストから非表示になります。",
"deckDeleteConfirm": "このデッキを削除しますか?",
"deckFavorite": "お気に入り",
"deckFavoriteFilter": "お気に入り",
"deckFavoriteFilterOnly": "お気に入りのみ",
"deckMoreActions": "その他",
"deckNoFavorites": "お気に入りのデッキがありません。",
"deckHoldPreviewHint": "クリック・長押しで拡大",
"pageHelpLabel": "ページの説明",
"all": "全て",
"metaDeck": "公開デッキ",
"titlePrompt": "タイトルを選択してください",

View File

@@ -44,6 +44,106 @@
"back": "BACK",
"saveError": "이미지 저장 실패",
"goHome": "홈으로",
"downloadImageLabel": "이미지로 다운로드",
"saveToMyDeckLabel": "내 덱으로 저장",
"savePublicTitle": "모두에게 공개할까요?",
"savePublicDesc": "공개하면「공개 덱」목록에 표시됩니다.",
"savePublicLabel": "공개",
"savePublicDefault": "기본값: 예",
"saveSkipLabel": "다음부터 물어보지 않기",
"saveSkipHint": "선택한 공개 여부가 기본값이 됩니다",
"saveButton": "저장",
"okButton": "확인",
"deckSavedPublic": "덱이 공개로 저장되었습니다.",
"deckSavedPrivate": "덱이 비공개로 저장되었습니다.",
"previewFailedNote": "(미리보기 이미지 생성에 실패했습니다)",
"redirectingHome": "잠시 후 홈으로 이동합니다.",
"saveFailedTitle": "저장에 실패했습니다.",
"tryAgain": "다시 시도해 주세요.",
"noCardsToSave": "저장할 카드가 없습니다.",
"homeNav": "← 홈",
"authLogin": "로그인",
"authSignup": "회원가입",
"authMyProfile": "내 프로필",
"authMyDecks": "내 덱 관리",
"authPublicDecks": "공개 덱",
"authAdmin": "관리자",
"authLogout": "로그아웃",
"authAccountMenu": "계정 메뉴",
"authAccountMenuNamed": "{name} 계정 메뉴",
"loginPageTitle": "로그인",
"loginPageDesc": "덱을 저장하고 관리하려면<br />계정으로 로그인하세요.",
"signupPageTitle": "회원가입",
"signupPageDesc": "UADECK 계정을 만들고<br />덱을 저장·공유해 보세요.",
"emailLabel": "이메일",
"passwordLabel": "비밀번호",
"usernameLabel": "사용자명",
"passwordConfirmLabel": "비밀번호 확인",
"passwordConfirmPlaceholder": "비밀번호 재입력",
"signupPasswordPlaceholder": "8자 이상",
"passwordMismatch": "비밀번호가 일치하지 않습니다.",
"signupButton": "가입하기",
"loginButton": "로그인",
"noAccount": "계정이 없으신가요?",
"hasAccount": "이미 계정이 있으신가요?",
"deckHistoryTitle": "내 덱 관리",
"deckHistoryDesc": "저장한 덱 목록입니다. 공개 전환 시「공개 덱」목록에 표시됩니다.",
"publicDecksTitle": "공개 덱",
"publicDecksDesc": "사용자가 공개로 전환한 덱 목록입니다. 내 덱 관리에서「공개 전환」하면 여기에 표시됩니다.",
"profileTitle": "내 프로필",
"profileDesc": "프로필 사진, 사용자명, 이메일, 비밀번호를 변경할 수 있습니다.",
"profileSave": "프로필 저장",
"profileDeckSettings": "덱 저장 설정",
"profileDeckSettingsDesc": "「내 덱으로 저장」시 공개 여부 기본값과 확인 방식을 설정합니다.",
"profileDefaultPublic": "기본 공개",
"profileDefaultPublicHint": "저장 시 기본으로 공개 덱에 노출",
"profileAskOnSave": "저장 시 공개 여부 물어보기",
"profileAskOnSaveHint": "끄면 설정한 기본값으로 바로 저장",
"profileDeckPrefsSaved": "덱 저장 설정이 적용되었습니다.",
"profileSettingOn": "ON",
"profileSettingOff": "OFF",
"changePassword": "비밀번호 변경",
"changePasswordButton": "변경",
"newPasswordLabel": "새 비밀번호",
"confirmPasswordLabel": "새 비밀번호 확인",
"loadingLabel": "불러오는 중...",
"deckSearchPlaceholder": "덱 이름 또는 시리즈 검색...",
"deckAllSeries": "모든 시리즈",
"deckLoading": "불러오는 중...",
"deckEmpty": "저장된 덱이 없습니다.",
"deckNoMatch": "조건에 맞는 덱이 없습니다.",
"deckUntitled": "이름 없음",
"deckNoPreview": "미리보기 없음",
"deckResaveHint": "Preview에서 다시 저장",
"deckBadgePublic": "공개",
"deckBadgePrivate": "비공개",
"deckMakePublic": "공개 전환",
"deckMakePrivate": "비공개 전환",
"deckLoad": "불러오기",
"deckCopyLink": "링크 복사",
"deckDelete": "삭제",
"deckViews": "조회",
"deckCountFormat": "{filtered} / {total}개",
"publicDeckEmpty": "공개된 덱이 없습니다.",
"publicDeckSearchPlaceholder": "덱 이름·시리즈·작성자 검색...",
"deckLinkCopied": "공유 링크가 복사되었습니다.",
"deckMakePublicConfirm": "이 덱을 공개 덱 목록에 표시합니다. 계속할까요?",
"deckMakePrivateConfirm": "비공개로 전환합니다. 공개 목록에서 숨겨집니다.",
"deckDeleteConfirm": "이 덱을 삭제하시겠습니까?",
"deckFavorite": "즐겨찾기",
"deckFavoriteFilter": "즐겨찾기",
"deckFavoriteFilterOnly": "즐겨찾기만 보기",
"deckMoreActions": "더 보기",
"deckNoFavorites": "즐겨찾기한 덱이 없습니다.",
"deckHoldPreviewHint": "클릭·길게 눌러 크게 보기",
"pageHelpLabel": "페이지 안내",
"all": "전체",
"metaDeck": "공개 덱",
"titlePrompt": "작품을 선택해주세요",

View File

@@ -98,7 +98,7 @@
<script type="module">
async function loadSeriesList() {
const urlParams = new URLSearchParams(window.location.search);
const lang = urlParams.get('lang') || 'ko';
const lang = urlParams.get('lang') || localStorage.getItem('lang') || 'ko';
try {
const response = await fetch(`./i18n/translations.${lang}.json`);
@@ -163,17 +163,24 @@
}
}
window.changeLanguage = function (lang) {
window.changeLanguage = async function (lang) {
localStorage.setItem('lang', lang);
const currentUrl = new URL(window.location.href);
currentUrl.searchParams.set('lang', lang);
window.history.pushState({}, '', currentUrl.toString());
loadSeriesList();
await loadSeriesList();
if (window.UaPageI18n?.applyPageI18n) {
await window.UaPageI18n.applyPageI18n();
}
if (window.UaDeckAuth?.refresh) {
await window.UaDeckAuth.refresh();
}
};
window.addEventListener('popstate', () => loadSeriesList());
loadSeriesList();
</script>
<script src="/script/page-i18n.js"></script>
<script src="/script/auth-header.js"></script>
</body>
</html>

View File

@@ -20,29 +20,30 @@
<main class="auth-main">
<header class="auth-header">
<h1>로그인</h1>
<p>덱을 저장하고 관리하려면<br />계정으로 로그인하세요.</p>
<h1 data-i18n="loginPageTitle">로그인</h1>
<p data-i18n-html="loginPageDesc">덱을 저장하고 관리하려면<br />계정으로 로그인하세요.</p>
</header>
<form id="login-form" class="auth-card">
<div class="auth-field">
<label class="auth-label" for="email">이메일</label>
<label class="auth-label" for="email" data-i18n="emailLabel">이메일</label>
<input type="email" id="email" required class="auth-input" placeholder="you@example.com" autocomplete="email" />
</div>
<div class="auth-field">
<label class="auth-label" for="password">비밀번호</label>
<label class="auth-label" for="password" data-i18n="passwordLabel">비밀번호</label>
<input type="password" id="password" required class="auth-input" placeholder="••••••••" autocomplete="current-password" />
</div>
<p id="error-msg" class="auth-error hidden"></p>
<button type="submit" class="auth-submit">로그인</button>
<button type="submit" class="auth-submit" data-i18n="loginButton">로그인</button>
</form>
<p class="auth-footer">
계정이 없으신가요?
<a href="/signup.html">회원가입</a>
<span data-i18n="noAccount">계정이 없으신가요?</span>
<a href="/signup.html" data-i18n="authSignup">회원가입</a>
</p>
</main>
<script src="/script/page-i18n.js"></script>
<script src="/script/api.js"></script>
<script>
document.getElementById('login-form').addEventListener('submit', async (e) => {

View File

@@ -5,12 +5,7 @@ server {
root /usr/share/nginx/html;
index index.html;
client_max_body_size 2m;
# 공유 링크: /d/x7Kp9m2Q → cardList?d=
location ~ ^/d/([A-Za-z0-9]+)$ {
return 302 /cardList.html?d=$1;
}
client_max_body_size 25m;
# API → 백엔드 컨테이너
location /api/ {
@@ -20,6 +15,7 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 25m;
}
# 정적 자산 캐시

View File

@@ -6,6 +6,9 @@ location ~ ^/d/([A-Za-z0-9]+)$ {
}
# API 프록시 (정적 사이트와 함께 사용)
# 덱 저장 시 미리보기 PNG(base64) 포함 — 2m 이하면 413 발생
client_max_body_size 25m;
location /api/ {
proxy_pass http://uadeck-api:3000;
proxy_http_version 1.1;
@@ -13,4 +16,5 @@ location /api/ {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 25m;
}

View File

@@ -26,35 +26,80 @@
<div class="app-body-layout">
<main class="app-main deck-history-main">
<header class="deck-history-header">
<h1>공개 덱</h1>
<p>사용자가 공개로 전환한 덱 목록입니다. 내 덱 관리에서「공개 전환」하면 여기에 표시됩니다.</p>
<div class="page-title-row">
<h1 data-i18n="publicDecksTitle">공개 덱</h1>
<button type="button" class="page-help-btn" data-i18n-tooltip="publicDecksDesc" aria-expanded="false">?</button>
</div>
</header>
<div id="deck-toolbar" class="deck-history-toolbar hidden">
<input type="search" id="deck-search" class="deck-search-input" placeholder="덱 이름·시리즈·작성자 검색..." autocomplete="off" />
<select id="deck-series-filter" class="deck-series-select" aria-label="시리즈 필터">
<option value="">모든 시리즈</option>
</select>
<span id="deck-count" class="deck-count-label"></span>
<div class="deck-toolbar-fav-row">
<button type="button" id="deck-favorite-filter" class="deck-favorite-filter" aria-pressed="false">
<span class="deck-favorite-filter-icon deck-star-icon" aria-hidden="true"></span>
<span data-i18n="deckFavoriteFilterOnly">즐겨찾기만 보기</span>
<span id="deck-fav-count" class="deck-fav-count"></span>
</button>
</div>
<div class="deck-toolbar-main-row">
<input type="search" id="deck-search" class="deck-search-input" data-i18n-placeholder="publicDeckSearchPlaceholder" placeholder="덱 이름·시리즈·작성자 검색..." autocomplete="off" />
<select id="deck-series-filter" class="deck-series-select" aria-label="시리즈 필터">
<option value="">모든 시리즈</option>
</select>
<span id="deck-count" class="deck-count-label"></span>
</div>
</div>
<div id="preset-list">
<div class="deck-status-message">불러오는 중...</div>
<div class="deck-status-message" data-i18n="deckLoading">불러오는 중...</div>
</div>
</main>
<aside class="app-ad-rail" aria-label="광고">
<div class="app-ad-slot app-ad-slot--skyscraper" id="ad-sidebar"></div>
<div class="app-ad-slot app-ad-slot--skyscraper" id="ad-sidebar">
<ins
class="adsbygoogle"
style="display: block"
data-ad-client="ca-pub-4516420168710424"
data-ad-slot="4260332296"
data-ad-format="auto"
data-full-width-responsive="true"
></ins>
</div>
</aside>
</div>
</div>
<script src="/script/api.js"></script>
<script src="/script/ads.js"></script>
<script src="/script/deck-favorites.js"></script>
<script src="/script/deck-preview-hold.js"></script>
<script src="/script/page-i18n.js"></script>
<script src="/script/page-help.js"></script>
<script src="/script/auth-header.js"></script>
<script>
/** @type {Array<object>} */
let allDecks = [];
/**
* @param {string} key
* @param {string} fallback
* @returns {string}
*/
function t(key, fallback) {
return window.UaPageI18n?.t(key, fallback) || fallback;
}
/**
* @param {number} filtered
* @param {number} total
* @returns {string}
*/
function formatDeckCount(filtered, total) {
return t('deckCountFormat', '{filtered} / {total}개')
.replace('{filtered}', String(filtered))
.replace('{total}', String(total));
}
function escapeHtml(str) {
return String(str ?? '')
.replace(/&/g, '&amp;')
@@ -72,7 +117,7 @@
});
const sorted = [...seriesMap.entries()].sort((a, b) => String(a[1]).localeCompare(String(b[1]), 'ko'));
select.innerHTML =
'<option value="">모든 시리즈</option>' +
`<option value="">${escapeHtml(t('deckAllSeries', '모든 시리즈'))}</option>` +
sorted.map(([key, label]) => `<option value="${escapeHtml(key)}">${escapeHtml(label)}</option>`).join('');
if (current && seriesMap.has(current)) select.value = current;
}
@@ -80,7 +125,10 @@
function getFilteredDecks() {
const query = document.getElementById('deck-search').value.trim().toLowerCase();
const series = document.getElementById('deck-series-filter').value;
const favOnly =
document.getElementById('deck-favorite-filter')?.getAttribute('aria-pressed') === 'true';
return allDecks.filter((d) => {
if (favOnly && !UaDeckFavorites.isFavorite(d.share_code)) return false;
if (series && d.series !== series) return false;
if (!query) return true;
const haystack = [d.name, d.series_name, d.series, d.author, d.share_code].filter(Boolean).join(' ').toLowerCase();
@@ -88,42 +136,89 @@
});
}
function renderPreviewHtml(d, title) {
if (!d.preview_url) {
return '<div class="deck-card-preview-placeholder">미리보기 없음</div>';
}
/**
* @param {object} d
* @returns {string|null}
*/
function getPreviewSrc(d) {
if (!d.preview_url) return null;
const src = UaDeckApi.resolveMediaUrl(d.preview_url);
const version = d.updated_at ? `?v=${encodeURIComponent(d.updated_at)}` : '';
return `<img src="${escapeHtml(src + version)}" alt="${title}" loading="lazy" class="deck-card-preview-img" />`;
return src + version;
}
/**
* @param {string} shareCode
* @returns {string}
*/
function countFavorites() {
return allDecks.filter((d) => UaDeckFavorites.isFavorite(d.share_code)).length;
}
function updateFavCount() {
const el = document.getElementById('deck-fav-count');
if (!el) return;
const n = countFavorites();
el.textContent = n > 0 ? String(n) : '';
}
function favoriteButtonHtml(shareCode) {
const on = UaDeckFavorites.isFavorite(shareCode);
const cls = on ? 'deck-card-btn-favorite is-on' : 'deck-card-btn-favorite';
const starCls = on ? 'deck-star-icon is-on' : 'deck-star-icon';
return `<button type="button" class="deck-card-btn ${cls}" data-favorite="${escapeHtml(shareCode)}" aria-pressed="${on ? 'true' : 'false'}"><span class="${starCls}" aria-hidden="true"></span>${escapeHtml(t('deckFavorite', '즐겨찾기'))}</button>`;
}
function renderPreviewHtml(d, title) {
const previewSrc = getPreviewSrc(d);
if (!previewSrc) {
return `<div class="deck-card-preview"><div class="deck-card-preview-placeholder">${escapeHtml(t('deckNoPreview', '미리보기 없음'))}</div></div>`;
}
const holdAttrs = `data-hold-preview data-preview-src="${escapeHtml(previewSrc)}"`;
return `<div class="deck-card-preview deck-card-preview--holdable" ${holdAttrs}><img src="${escapeHtml(previewSrc)}" alt="${title}" loading="lazy" class="deck-card-preview-img" draggable="false" /></div>`;
}
function renderDeckCard(d) {
const title = escapeHtml(d.name || d.series_name || d.series);
const seriesLabel = escapeHtml(d.series_name || d.series);
const date = new Date(d.updated_at).toLocaleDateString('ko-KR');
const favClass = UaDeckFavorites.isFavorite(d.share_code) ? 'deck-card is-favorite' : 'deck-card';
return `
<a href="/cardList.html?series=${escapeHtml(d.series)}&seriesName=${encodeURIComponent(d.series_name || '')}&d=${escapeHtml(d.share_code)}" class="deck-card" style="text-decoration:none;color:inherit">
<div class="deck-card-preview">${renderPreviewHtml(d, title)}</div>
<article class="${favClass}" data-code="${escapeHtml(d.share_code)}">
${renderPreviewHtml(d, title)}
<div class="deck-card-body">
<span class="deck-card-series">${seriesLabel}</span>
<h2 class="deck-card-title">${title}</h2>
<div class="deck-card-meta">${escapeHtml(d.author)} · 조회 ${d.view_count} · ${date}</div>
<div class="deck-card-meta">${escapeHtml(d.author)} · ${escapeHtml(t('deckViews', '조회'))} ${d.view_count} · ${date}</div>
<div class="deck-card-actions">
${favoriteButtonHtml(d.share_code)}
<button type="button" class="deck-card-btn deck-card-btn-load" data-load="${escapeHtml(d.share_code)}" data-series="${escapeHtml(d.series)}" data-name="${escapeHtml(d.series_name || '')}">${escapeHtml(t('deckLoad', '불러오기'))}</button>
</div>
</div>
</a>`;
</article>`;
}
function renderList() {
const list = document.getElementById('preset-list');
const countEl = document.getElementById('deck-count');
const filtered = getFilteredDecks();
countEl.textContent = `${filtered.length} / ${allDecks.length}개`;
countEl.textContent = formatDeckCount(filtered.length, allDecks.length);
updateFavCount();
if (!filtered.length) {
list.innerHTML = '<div class="deck-grid"><div class="deck-grid-empty">공개된 덱이 없습니다.</div></div>';
const favOnly =
document.getElementById('deck-favorite-filter')?.getAttribute('aria-pressed') === 'true';
const emptyMsg = favOnly
? t('deckNoFavorites', '즐겨찾기한 덱이 없습니다.')
: allDecks.length
? t('deckNoMatch', '조건에 맞는 덱이 없습니다.')
: t('publicDeckEmpty', '공개된 덱이 없습니다.');
list.innerHTML = `<div class="deck-grid"><div class="deck-grid-empty">${escapeHtml(emptyMsg)}</div></div>`;
return;
}
list.innerHTML = `<div class="deck-grid">${filtered.map(renderDeckCard).join('')}</div>`;
UaDeckPreviewHold.bindHoldPreview(list);
}
async function loadPublicDecks() {
@@ -134,7 +229,7 @@
allDecks = presets;
if (!presets.length) {
toolbar.classList.add('hidden');
list.innerHTML = '<div class="deck-status-message">공개된 덱이 없습니다.</div>';
list.innerHTML = `<div class="deck-status-message">${escapeHtml(t('publicDeckEmpty', '공개된 덱이 없습니다.'))}</div>`;
return;
}
toolbar.classList.remove('hidden');
@@ -146,10 +241,39 @@
}
}
document.addEventListener('DOMContentLoaded', () => {
document.addEventListener('DOMContentLoaded', async () => {
if (window.UaPageI18n?.applyPageI18n) {
await window.UaPageI18n.applyPageI18n();
}
loadPublicDecks();
document.getElementById('deck-search').addEventListener('input', renderList);
document.getElementById('deck-series-filter').addEventListener('change', renderList);
const favFilterBtn = document.getElementById('deck-favorite-filter');
const favFilterIcon = favFilterBtn.querySelector('.deck-star-icon');
favFilterBtn.addEventListener('click', () => {
const on = favFilterBtn.getAttribute('aria-pressed') !== 'true';
favFilterBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
favFilterIcon.classList.toggle('is-on', on);
renderList();
});
document.getElementById('preset-list').addEventListener('click', (e) => {
const favBtn = e.target.closest('[data-favorite]');
if (favBtn) {
UaDeckFavorites.toggle(favBtn.dataset.favorite);
renderList();
return;
}
const loadBtn = e.target.closest('[data-load]');
if (loadBtn) {
const code = loadBtn.dataset.load;
const series = loadBtn.dataset.series;
const name = loadBtn.dataset.name;
location.href = `/cardList.html?series=${series}&seriesName=${encodeURIComponent(name)}&d=${code}`;
}
});
});
</script>
</body>

View File

@@ -26,13 +26,15 @@
<div class="app-body-layout">
<main class="app-main profile-content">
<header class="profile-header">
<h1>내 프로필</h1>
<p>프로필 사진, 사용자명, 이메일, 비밀번호를 변경할 수 있습니다.</p>
<div class="page-title-row">
<h1 data-i18n="profileTitle">내 프로필</h1>
<button type="button" class="page-help-btn" data-i18n-tooltip="profileDesc" aria-expanded="false">?</button>
</div>
</header>
<div id="profile-loading" class="profile-card">
<div class="profile-body">
<p style="margin: 0; color: #64748b; font-size: 0.875rem">불러오는 중...</p>
<p data-i18n="loadingLabel" style="margin: 0; color: #64748b; font-size: 0.875rem">불러오는 중...</p>
</div>
</div>
@@ -54,15 +56,15 @@
<div class="profile-body">
<div class="profile-field">
<label class="profile-label" for="profile-email">이메일</label>
<label class="profile-label" for="profile-email" data-i18n="emailLabel">이메일</label>
<input type="email" id="profile-email" class="profile-input" autocomplete="email" />
</div>
<div class="profile-field">
<label class="profile-label" for="profile-username">사용자명</label>
<label class="profile-label" for="profile-username" data-i18n="usernameLabel">사용자명</label>
<input type="text" id="profile-username" class="profile-input" autocomplete="username" />
</div>
<button type="button" id="profile-save" class="profile-btn profile-btn-primary" disabled>
<button type="button" id="profile-save" class="profile-btn profile-btn-primary" disabled data-i18n="profileSave">
프로필 저장
</button>
<p id="profile-error" class="profile-error hidden"></p>
@@ -70,34 +72,45 @@
<div class="profile-divider"></div>
<h3 class="profile-section-title">덱 저장 설정</h3>
<p class="profile-section-desc">「내 덱으로 저장」시 공개 여부 기본값과 확인 방식을 설정합니다.</p>
<div class="profile-toggle-row">
<div>
<div class="profile-toggle-label">기본 공개</div>
<div class="profile-toggle-hint">저장 시 기본으로 공개 덱에 노출</div>
</div>
<button type="button" id="pref-public-toggle" class="profile-toggle" aria-pressed="true">
<span id="pref-public-circle" class="profile-toggle-circle is-on"></span>
</button>
<input type="checkbox" id="pref-public-check" class="hidden" checked />
<div class="profile-section-head">
<h3 class="profile-section-title" data-i18n="profileDeckSettings">덱 저장 설정</h3>
<button type="button" class="page-help-btn" data-i18n-tooltip="profileDeckSettingsDesc" aria-expanded="false">?</button>
</div>
<div class="profile-toggle-row">
<div>
<div class="profile-toggle-label">저장 시 공개 여부 물어보기</div>
<div class="profile-toggle-hint">끄면 설정한 기본값으로 바로 저장</div>
<div class="profile-settings-panel">
<div class="profile-toggle-row">
<label class="profile-toggle-text" for="pref-public-toggle">
<span class="profile-toggle-label" data-i18n="profileDefaultPublic">기본 공개</span>
<span class="profile-toggle-hint" data-i18n="profileDefaultPublicHint">저장 시 기본으로 공개 덱에 노출</span>
</label>
<div class="profile-toggle-wrap">
<span id="pref-public-state" class="profile-toggle-state is-on" data-i18n-on="profileSettingOn" data-i18n-off="profileSettingOff">ON</span>
<button type="button" id="pref-public-toggle" class="profile-toggle is-on" aria-pressed="true">
<span id="pref-public-circle" class="profile-toggle-circle is-on"></span>
</button>
<input type="checkbox" id="pref-public-check" class="hidden" checked />
</div>
</div>
<div class="profile-toggle-row">
<label class="profile-toggle-text" for="pref-ask-toggle">
<span class="profile-toggle-label" data-i18n="profileAskOnSave">저장 시 공개 여부 물어보기</span>
<span class="profile-toggle-hint" data-i18n="profileAskOnSaveHint">끄면 설정한 기본값으로 바로 저장</span>
</label>
<div class="profile-toggle-wrap">
<span id="pref-ask-state" class="profile-toggle-state is-on" data-i18n-on="profileSettingOn" data-i18n-off="profileSettingOff">ON</span>
<button type="button" id="pref-ask-toggle" class="profile-toggle is-on" aria-pressed="true">
<span id="pref-ask-circle" class="profile-toggle-circle is-on"></span>
</button>
<input type="checkbox" id="pref-ask-check" class="hidden" checked />
</div>
</div>
<button type="button" id="pref-ask-toggle" class="profile-toggle" aria-pressed="true">
<span id="pref-ask-circle" class="profile-toggle-circle is-on"></span>
</button>
<input type="checkbox" id="pref-ask-check" class="hidden" checked />
</div>
<p id="profile-prefs-notice" class="profile-prefs-notice" aria-live="polite"></p>
<div class="profile-divider"></div>
<button type="button" id="profile-open-password" class="profile-btn profile-btn-outline">
<button type="button" id="profile-open-password" class="profile-btn profile-btn-outline" data-i18n="changePassword">
비밀번호 변경
</button>
</div>
@@ -105,35 +118,47 @@
</main>
<aside class="app-ad-rail" aria-label="광고">
<div class="app-ad-slot app-ad-slot--skyscraper" id="ad-sidebar"></div>
<div class="app-ad-slot app-ad-slot--skyscraper" id="ad-sidebar">
<ins
class="adsbygoogle"
style="display: block"
data-ad-client="ca-pub-4516420168710424"
data-ad-slot="4260332296"
data-ad-format="auto"
data-full-width-responsive="true"
></ins>
</div>
</aside>
</div>
</div>
<div id="password-modal" class="profile-modal">
<div class="profile-modal-box">
<h3>비밀번호 변경</h3>
<h3 data-i18n="changePassword">비밀번호 변경</h3>
<div class="profile-field">
<label class="profile-label" for="password-current">현재 비밀번호</label>
<label class="profile-label" for="password-current" data-i18n="passwordLabel">현재 비밀번호</label>
<input type="password" id="password-current" class="profile-input" autocomplete="current-password" />
</div>
<div class="profile-field">
<label class="profile-label" for="password-new">새 비밀번호</label>
<label class="profile-label" for="password-new" data-i18n="newPasswordLabel">새 비밀번호</label>
<input type="password" id="password-new" class="profile-input" autocomplete="new-password" />
</div>
<div class="profile-field">
<label class="profile-label" for="password-confirm">새 비밀번호 확인</label>
<label class="profile-label" for="password-confirm" data-i18n="confirmPasswordLabel">새 비밀번호 확인</label>
<input type="password" id="password-confirm" class="profile-input" autocomplete="new-password" />
</div>
<p id="password-error" class="profile-error hidden"></p>
<div class="profile-modal-actions">
<button type="button" id="password-cancel" class="profile-btn profile-btn-outline">취소</button>
<button type="button" id="password-save" class="profile-btn profile-btn-primary">변경</button>
<button type="button" id="password-cancel" class="profile-btn profile-btn-outline" data-i18n="cancel">취소</button>
<button type="button" id="password-save" class="profile-btn profile-btn-primary" data-i18n="changePasswordButton">변경</button>
</div>
</div>
</div>
<script src="/script/api.js"></script>
<script src="/script/ads.js"></script>
<script src="/script/page-i18n.js"></script>
<script src="/script/page-help.js"></script>
<script src="/script/deck-prefs.js"></script>
<script src="/script/image-utils.js"></script>
<script src="/script/auth-header.js"></script>
@@ -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();
});

View File

@@ -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>

20
script/ads.js Normal file
View 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();
}
})();

View File

@@ -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 서버가 실행 중인지 확인하세요.';
}

View File

@@ -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
View 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
View 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
View 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
View 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 };
})();

View File

@@ -20,33 +20,38 @@
<main class="auth-main">
<header class="auth-header">
<h1>회원가입</h1>
<p>UADECK 계정을 만들고<br />덱을 저장·공유해 보세요.</p>
<h1 data-i18n="signupPageTitle">회원가입</h1>
<p data-i18n-html="signupPageDesc">UADECK 계정을 만들고<br />덱을 저장·공유해 보세요.</p>
</header>
<form id="signup-form" class="auth-card">
<div class="auth-field">
<label class="auth-label" for="email">이메일</label>
<label class="auth-label" for="email" data-i18n="emailLabel">이메일</label>
<input type="email" id="email" required class="auth-input" placeholder="you@example.com" autocomplete="email" />
</div>
<div class="auth-field">
<label class="auth-label" for="username">사용자명</label>
<label class="auth-label" for="username" data-i18n="usernameLabel">사용자명</label>
<input type="text" id="username" required minlength="2" class="auth-input" placeholder="deckmaster" autocomplete="username" />
</div>
<div class="auth-field">
<label class="auth-label" for="password">비밀번호</label>
<input type="password" id="password" required minlength="8" class="auth-input" placeholder="8자 이상" autocomplete="new-password" />
<label class="auth-label" for="password" data-i18n="passwordLabel">비밀번호</label>
<input type="password" id="password" required minlength="8" class="auth-input" data-i18n-placeholder="signupPasswordPlaceholder" placeholder="8자 이상" autocomplete="new-password" />
</div>
<div class="auth-field">
<label class="auth-label" for="password-confirm" data-i18n="passwordConfirmLabel">비밀번호 확인</label>
<input type="password" id="password-confirm" required minlength="8" class="auth-input" data-i18n-placeholder="passwordConfirmPlaceholder" placeholder="비밀번호 재입력" autocomplete="new-password" />
</div>
<p id="error-msg" class="auth-error hidden"></p>
<button type="submit" class="auth-submit">가입하기</button>
<button type="submit" class="auth-submit" data-i18n="signupButton">가입하기</button>
</form>
<p class="auth-footer">
이미 계정이 있으신가요?
<a href="/login.html">로그인</a>
<span data-i18n="hasAccount">이미 계정이 있으신가요?</span>
<a href="/login.html" data-i18n="authLogin">로그인</a>
</p>
</main>
<script src="/script/page-i18n.js"></script>
<script src="/script/api.js"></script>
<script>
document.getElementById('signup-form').addEventListener('submit', async (e) => {
@@ -54,14 +59,23 @@
const errEl = document.getElementById('error-msg');
errEl.classList.add('hidden');
try {
const password = document.getElementById('password').value;
const passwordConfirm = document.getElementById('password-confirm').value;
if (password !== passwordConfirm) {
const msg = window.UaPageI18n?.t('passwordMismatch', '비밀번호가 일치하지 않습니다.')
|| '비밀번호가 일치하지 않습니다.';
errEl.textContent = msg;
errEl.classList.remove('hidden');
return;
}
const body = {
email: document.getElementById('email').value,
username: document.getElementById('username').value,
password: document.getElementById('password').value,
password,
};
const { token, user } = await UaDeckApi.register(body);
UaDeckApi.saveAuth(token, user);
location.href = '/deckHistory.html';
location.href = '/';
} catch (err) {
errEl.textContent = err.message;
errEl.classList.remove('hidden');

View File

@@ -43,7 +43,6 @@
.admin-main {
max-width: 80rem;
margin: 0 auto;
padding: 3rem 1.5rem 5rem;
}
@media (min-width: 640px) {

View File

@@ -101,6 +101,12 @@
width: 100%;
}
.app-ad-slot--skyscraper:has(ins.adsbygoogle) {
min-height: auto;
border: none;
background: transparent;
}
.app-ad-slot ins {
display: block;
width: 100%;
@@ -130,3 +136,53 @@
display: none;
}
}
/* 페이지 제목 + ? 도움말 */
.page-title-row {
display: flex;
align-items: center;
gap: 0.625rem;
}
.page-title-row h1 {
margin: 0;
flex: 1;
min-width: 0;
}
.page-help-btn {
flex-shrink: 0;
width: 1.625rem;
height: 1.625rem;
border-radius: 50%;
border: 1px solid #cbd5e1;
background: #f8fafc;
color: #64748b;
font-size: 0.75rem;
font-weight: 800;
cursor: pointer;
line-height: 1;
padding: 0;
transition: background 0.2s, border-color 0.2s, color 0.2s;
}
.page-help-btn:hover,
.page-help-btn[aria-expanded='true'] {
background: #eff6ff;
border-color: #93c5fd;
color: #2563eb;
}
.page-help-tooltip {
position: absolute;
z-index: 300;
max-width: min(22rem, calc(100vw - 2rem));
padding: 0.75rem 1rem;
font-size: 0.8125rem;
line-height: 1.55;
color: #334155;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 0.75rem;
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.12);
}

View File

@@ -2,68 +2,69 @@
position: relative;
}
.auth-avatar-btn {
.auth-profile-trigger {
display: inline-flex;
align-items: center;
gap: 0.5rem;
max-width: 12.5rem;
padding: 0.125rem 0.5rem 0.125rem 0.125rem;
border: none;
border-radius: 9999px;
background: transparent;
cursor: pointer;
transition: background 0.2s, box-shadow 0.2s;
}
.auth-profile-trigger:hover {
background: #f1f5f9;
}
.auth-profile-trigger:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
}
.auth-profile-trigger--guest {
padding: 0;
}
.auth-profile-trigger--named {
padding-right: 0.625rem;
}
.auth-avatar-circle {
flex-shrink: 0;
width: 36px;
height: 36px;
border-radius: 9999px;
border: 2px solid #e2e8f0;
background: #f1f5f9;
cursor: pointer;
overflow: hidden;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0;
padding: 0;
transition: border-color 0.2s, box-shadow 0.2s;
}
.auth-avatar-btn.auth-profile-btn--named {
padding-right: 12px;
gap: 8px;
max-width: 180px;
}
.auth-avatar-btn:hover {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
.auth-avatar-wrap {
flex-shrink: 0;
width: 32px;
height: 32px;
margin: 2px;
border-radius: 9999px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
transition: border-color 0.2s;
}
.auth-avatar-wrap img {
.auth-profile-trigger:hover .auth-avatar-circle {
border-color: #3b82f6;
}
.auth-avatar-circle img {
width: 100%;
height: 100%;
object-fit: cover;
}
.auth-username {
font-size: 13px;
font-size: 0.8125rem;
font-weight: 700;
color: #334155;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.auth-avatar-btn:not(.auth-profile-btn--named) {
width: 36px;
}
.auth-avatar-btn:not(.auth-profile-btn--named) .auth-avatar-wrap {
width: 100%;
height: 100%;
margin: 0;
line-height: 1.2;
}
.auth-dropdown {

View File

@@ -18,22 +18,17 @@
font-size: 1.75rem;
font-weight: 900;
color: #0f172a;
margin: 0 0 0.5rem;
letter-spacing: -0.02em;
}
.deck-history-header p {
font-size: 0.875rem;
color: #64748b;
margin: 0;
line-height: 1.5;
.deck-history-header .page-title-row {
margin-bottom: 0;
}
.deck-history-toolbar {
display: flex;
flex-wrap: wrap;
flex-direction: column;
gap: 0.75rem;
align-items: center;
margin-bottom: 1.5rem;
padding: 1rem 1.25rem;
background: #fff;
@@ -46,9 +41,36 @@
display: none;
}
.deck-search-input {
.deck-toolbar-fav-row {
display: flex;
width: 100%;
padding-bottom: 0.75rem;
border-bottom: 1px solid #f1f5f9;
}
.deck-toolbar-main-row {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
width: 100%;
}
.deck-toolbar-main-row .deck-search-input {
flex: 1 1 12rem;
min-width: 0;
}
.deck-toolbar-main-row .deck-series-select {
flex: 0 1 11rem;
min-width: 9rem;
}
.deck-toolbar-main-row .deck-count-label {
margin-left: auto;
}
.deck-search-input {
border: 1px solid #e2e8f0;
border-radius: 0.75rem;
padding: 0.625rem 1rem;
@@ -70,8 +92,6 @@
}
.deck-series-select {
flex: 0 1 11rem;
min-width: 9rem;
border: 1px solid #e2e8f0;
border-radius: 0.75rem;
padding: 0.625rem 2rem 0.625rem 1rem;
@@ -97,6 +117,74 @@
white-space: nowrap;
}
.deck-favorite-filter {
display: inline-flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.625rem 1rem;
border: 1px solid #fde68a;
border-radius: 0.75rem;
font-size: 0.8125rem;
font-weight: 700;
color: #92400e;
background: #fffbeb;
cursor: pointer;
transition: background 0.2s, border-color 0.2s, box-shadow 0.2s;
}
.deck-favorite-filter:hover {
background: #fef3c7;
border-color: #fcd34d;
}
.deck-favorite-filter[aria-pressed='true'] {
color: #78350f;
background: #fef08a;
border-color: #f59e0b;
box-shadow: inset 0 0 0 1px rgba(245, 158, 11, 0.35);
}
.deck-favorite-filter-icon {
font-size: 1.125rem;
line-height: 1;
color: #f59e0b;
}
.deck-favorite-filter .deck-star-icon {
color: #fbbf24;
}
.deck-favorite-filter .deck-star-icon.is-on {
color: #d97706;
}
.deck-fav-count {
margin-left: auto;
font-size: 0.75rem;
font-weight: 800;
color: #b45309;
}
.deck-star-icon {
display: inline-block;
font-size: 1rem;
line-height: 1;
color: #cbd5e1;
}
.deck-star-icon::before {
content: '☆';
}
.deck-star-icon.is-on {
color: #f59e0b;
}
.deck-star-icon.is-on::before {
content: '★';
}
.deck-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
@@ -117,7 +205,7 @@
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 1rem;
overflow: hidden;
overflow: visible;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
transition: box-shadow 0.2s, transform 0.2s, border-color 0.2s;
}
@@ -128,12 +216,18 @@
transform: translateY(-2px);
}
.deck-card.is-favorite {
border-color: #fcd34d;
box-shadow: 0 2px 12px rgba(245, 158, 11, 0.12);
}
.deck-card-preview {
position: relative;
aspect-ratio: 16 / 9;
background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%);
border-bottom: 1px solid #e2e8f0;
overflow: hidden;
border-radius: 1rem 1rem 0 0;
}
.deck-card-preview img {
@@ -141,7 +235,18 @@
height: 100%;
object-fit: contain;
object-position: center;
background: #f8fafc;
background-color: #ffffff;
}
.deck-card-preview--holdable {
cursor: zoom-in;
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
}
.deck-card-preview--holdable:active {
opacity: 0.92;
}
.deck-card-preview-placeholder {
@@ -213,6 +318,83 @@
gap: 0.5rem;
margin-top: auto;
padding-top: 0.5rem;
align-items: center;
}
.deck-card-actions--primary .deck-card-btn-load {
flex: 1.4;
}
.deck-card-more-wrap {
position: relative;
flex: 0 0 auto;
}
.deck-card-more-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
padding: 0;
border: 1px solid #e2e8f0;
border-radius: 0.625rem;
background: #f8fafc;
color: #64748b;
cursor: pointer;
transition: background 0.2s, border-color 0.2s, color 0.2s;
}
.deck-card-more-btn:hover,
.deck-card-more-btn[aria-expanded='true'] {
background: #fff;
border-color: #cbd5e1;
color: #334155;
}
.deck-card-more-menu {
position: absolute;
right: 0;
top: calc(100% + 0.35rem);
z-index: 20;
min-width: 9.5rem;
padding: 0.35rem;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 0.75rem;
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.12);
}
.deck-card-more-menu.hidden {
display: none;
}
.deck-card-more-item {
display: block;
width: 100%;
padding: 0.55rem 0.75rem;
border: none;
border-radius: 0.5rem;
background: transparent;
color: #334155;
font-size: 0.75rem;
font-weight: 600;
text-align: left;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s;
}
.deck-card-more-item:hover {
background: #f1f5f9;
}
.deck-card-more-item--danger {
color: #dc2626;
}
.deck-card-more-item--danger:hover {
background: #fef2f2;
}
.deck-card-btn {
@@ -240,6 +422,31 @@
background: #1d4ed8;
}
.deck-card-btn-favorite {
background: #fffbeb;
color: #92400e;
border: 1px solid #fde68a;
}
.deck-card-btn-favorite:hover {
background: #fef3c7;
border-color: #fcd34d;
}
.deck-card-btn-favorite.is-on {
background: #fef08a;
color: #78350f;
border-color: #f59e0b;
}
.deck-card-btn-favorite.is-on:hover {
background: #fde047;
}
.deck-card-btn-favorite .deck-star-icon {
margin-right: 0.15rem;
}
.deck-card-btn-copy {
background: #f1f5f9;
color: #475569;
@@ -316,3 +523,39 @@
.deck-status-message.error {
color: #ef4444;
}
.deck-preview-hold-overlay {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
background: rgba(15, 23, 42, 0.72);
backdrop-filter: blur(4px);
cursor: zoom-out;
}
.deck-preview-hold-overlay.hidden {
display: none;
}
.deck-preview-hold-box {
max-width: min(92vw, 56rem);
max-height: min(88vh, 40rem);
background: #fff;
border-radius: 1rem;
padding: 0.75rem;
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.25);
}
.deck-preview-hold-box img {
display: block;
max-width: 100%;
max-height: min(84vh, 38rem);
width: auto;
height: auto;
margin: 0 auto;
object-fit: contain;
}

View File

@@ -15,19 +15,16 @@
margin-bottom: 1.5rem;
}
.profile-header .page-title-row {
margin-bottom: 0;
}
.profile-header h1 {
margin: 0 0 0.5rem;
font-size: 1.75rem;
font-weight: 900;
letter-spacing: -0.02em;
}
.profile-header p {
margin: 0;
font-size: 0.875rem;
color: #64748b;
}
.profile-card {
background: #fff;
border: 1px solid #e2e8f0;
@@ -163,12 +160,32 @@
}
.profile-section-title {
margin: 0 0 0.375rem;
font-size: 0.9375rem;
margin: 0;
font-size: 1rem;
font-weight: 800;
color: #0f172a;
}
.profile-section-head {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.profile-section-head .profile-section-title {
flex: 1;
min-width: 0;
}
.profile-settings-panel {
margin-bottom: 0.5rem;
padding: 0.25rem 1rem;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 1rem;
}
.profile-section-desc {
margin: 0 0 1rem;
font-size: 0.8125rem;
@@ -180,35 +197,64 @@
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 0;
padding: 0.875rem 0;
}
.profile-toggle-row + .profile-toggle-row {
border-top: 1px solid #f1f5f9;
border-top: 1px solid #e2e8f0;
}
.profile-toggle-text {
flex: 1;
min-width: 0;
cursor: pointer;
}
.profile-toggle-label {
display: block;
font-size: 0.875rem;
font-weight: 600;
color: #334155;
font-weight: 700;
color: #0f172a;
}
.profile-toggle-hint {
display: block;
font-size: 0.75rem;
color: #64748b;
margin-top: 0.2rem;
line-height: 1.4;
}
.profile-toggle-wrap {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 0.5rem;
}
.profile-toggle-state {
min-width: 1.75rem;
font-size: 0.6875rem;
font-weight: 800;
letter-spacing: 0.04em;
text-align: right;
color: #94a3b8;
margin-top: 0.125rem;
}
.profile-toggle-state.is-on {
color: #2563eb;
}
.profile-toggle {
position: relative;
flex-shrink: 0;
display: inline-flex;
width: 2.75rem;
height: 1.5rem;
width: 3rem;
height: 1.625rem;
align-items: center;
border: none;
border-radius: 9999px;
background: #e2e8f0;
background: #cbd5e1;
cursor: pointer;
transition: background 0.2s;
padding: 0;
@@ -218,19 +264,37 @@
background: #2563eb;
}
.profile-toggle:focus-visible {
outline: 2px solid #93c5fd;
outline-offset: 2px;
}
.profile-toggle-circle {
display: inline-block;
width: 1rem;
height: 1rem;
width: 1.25rem;
height: 1.25rem;
border-radius: 9999px;
background: #fff;
transform: translateX(0.25rem);
transform: translateX(0.2rem);
transition: transform 0.2s;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.15);
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.2);
pointer-events: none;
}
.profile-toggle-circle.is-on {
transform: translateX(1.5rem);
transform: translateX(1.55rem);
}
.profile-prefs-notice {
margin: 0.5rem 0 0;
font-size: 0.75rem;
font-weight: 600;
color: #059669;
min-height: 1.125rem;
}
.profile-prefs-notice:empty {
display: none;
}
.profile-btn-outline {