v4.0.0: 덱 UX·i18n·썸네일 저장 개선 및 413 수정
즐겨찾기·홀드 미리보기·페이지 도움말·3개국어 번역을 추가하고, 덱 저장 시 썸네일을 1200px로 통일하며 Nginx/API 업로드 제한을 상향했다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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: '서버 오류가 발생했습니다.' });
|
||||
});
|
||||
|
||||
118
cardList.html
118
cardList.html
@@ -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() {
|
||||
|
||||
258
deckHistory.html
258
deckHistory.html
@@ -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);
|
||||
|
||||
15
docs/map.md
15
docs/map.md
@@ -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`
|
||||
|
||||
@@ -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`: 일반 사용자 프로필 화면 (아바타·닉네임·이메일·비밀번호)
|
||||
|
||||
@@ -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 — 덱 썸네일 수정
|
||||
|
||||
### 수행 작업
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "タイトルを選択してください",
|
||||
|
||||
@@ -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": "작품을 선택해주세요",
|
||||
|
||||
13
index.html
13
index.html
@@ -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>
|
||||
|
||||
15
login.html
15
login.html
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
# 정적 자산 캐시
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
170
preset.html
170
preset.html
@@ -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, '&')
|
||||
@@ -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>
|
||||
|
||||
163
profile.html
163
profile.html
@@ -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();
|
||||
});
|
||||
|
||||
311
result.html
311
result.html
@@ -158,7 +158,6 @@
|
||||
}
|
||||
|
||||
.result-btn {
|
||||
flex: 1;
|
||||
padding: 1rem 0.5rem;
|
||||
border: none;
|
||||
border-radius: 1rem;
|
||||
@@ -168,6 +167,17 @@
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, transform 0.1s;
|
||||
white-space: nowrap;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.result-btn-home,
|
||||
.result-btn-download {
|
||||
flex: 0.5 1 0;
|
||||
}
|
||||
|
||||
.result-btn-save.result-btn-save-footer {
|
||||
flex: 2 1 0;
|
||||
}
|
||||
|
||||
.result-btn:active {
|
||||
@@ -252,6 +262,40 @@
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.save-deck-switch {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
width: 2.75rem;
|
||||
height: 1.5rem;
|
||||
align-items: center;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
background: #e2e8f0;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.save-deck-switch.is-on {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.save-deck-switch-knob {
|
||||
display: block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 9999px;
|
||||
background: #fff;
|
||||
transform: translateX(0.25rem);
|
||||
transition: transform 0.2s;
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.15);
|
||||
}
|
||||
|
||||
.save-deck-switch.is-on .save-deck-switch-knob {
|
||||
transform: translateX(1.5rem);
|
||||
}
|
||||
|
||||
.save-deck-modal-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@@ -323,6 +367,38 @@
|
||||
color: #94a3b8;
|
||||
text-align: center;
|
||||
min-height: 1.125rem;
|
||||
white-space: pre-line;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.save-loading-message.is-success {
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.save-loading-message.is-error {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.save-loading-progress-wrap.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.save-loading-confirm {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 1.25rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: #2563eb;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.save-loading-confirm.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
@@ -338,10 +414,13 @@
|
||||
<div id="loading-screen" class="save-loading-overlay">
|
||||
<div class="save-loading-box">
|
||||
<p id="loading-message" class="save-loading-message"></p>
|
||||
<div class="save-loading-bar-track">
|
||||
<div id="loading-bar-fill" class="save-loading-bar-fill"></div>
|
||||
<div id="loading-progress-wrap" class="save-loading-progress-wrap">
|
||||
<div class="save-loading-bar-track">
|
||||
<div id="loading-bar-fill" class="save-loading-bar-fill"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p id="loading-submessage" class="save-loading-submessage"></p>
|
||||
<button type="button" id="loading-confirm-btn" class="save-loading-confirm hidden">확인</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -404,32 +483,32 @@
|
||||
<div class="result-footer-inner">
|
||||
<button type="button" id="btn-go-home" onclick="goHome()" class="result-btn result-btn-home">홈으로</button>
|
||||
<button type="button" id="i18n-btnDownload" onclick="saveAsImage()" class="result-btn result-btn-download">이미지로 다운로드</button>
|
||||
<button type="button" id="btn-save-deck" onclick="saveToMyDeck()" class="result-btn result-btn-save">내 덱으로 저장</button>
|
||||
<button type="button" id="btn-save-deck" onclick="saveToMyDeck()" class="result-btn result-btn-save result-btn-save-footer">내 덱으로 저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="save-deck-modal" class="save-deck-modal">
|
||||
<div class="save-deck-modal-box">
|
||||
<h3>모두에게 공개할까요?</h3>
|
||||
<p class="save-deck-modal-desc">공개하면「공개 덱」목록에 표시됩니다.</p>
|
||||
<h3 id="save-modal-title">모두에게 공개할까요?</h3>
|
||||
<p id="save-modal-desc" class="save-deck-modal-desc">공개하면「공개 덱」목록에 표시됩니다.</p>
|
||||
<div class="save-deck-toggle-row">
|
||||
<div>
|
||||
<div class="save-deck-toggle-label">공개</div>
|
||||
<div class="save-deck-toggle-hint">기본값: 예</div>
|
||||
<div id="save-public-label" class="save-deck-toggle-label">공개</div>
|
||||
<div id="save-public-hint" class="save-deck-toggle-hint">기본값: 예</div>
|
||||
</div>
|
||||
<button type="button" id="save-public-toggle" class="relative inline-flex h-6 w-11 items-center rounded-full bg-blue-600 transition-colors">
|
||||
<span id="save-public-circle" class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform translate-x-6"></span>
|
||||
<button type="button" id="save-public-toggle" class="save-deck-switch is-on" aria-pressed="true">
|
||||
<span id="save-public-circle" class="save-deck-switch-knob"></span>
|
||||
</button>
|
||||
<input type="checkbox" id="save-public-check" class="hidden" checked />
|
||||
</div>
|
||||
<div class="save-deck-toggle-row">
|
||||
<div>
|
||||
<div class="save-deck-toggle-label">다음부터 물어보지 않기</div>
|
||||
<div class="save-deck-toggle-hint">선택한 공개 여부가 기본값이 됩니다</div>
|
||||
<div id="save-skip-label" class="save-deck-toggle-label">다음부터 물어보지 않기</div>
|
||||
<div id="save-skip-hint" class="save-deck-toggle-hint">선택한 공개 여부가 기본값이 됩니다</div>
|
||||
</div>
|
||||
<button type="button" id="save-skip-toggle" class="relative inline-flex h-6 w-11 items-center rounded-full bg-slate-200 transition-colors">
|
||||
<span id="save-skip-circle" class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform translate-x-1"></span>
|
||||
<button type="button" id="save-skip-toggle" class="save-deck-switch" aria-pressed="false">
|
||||
<span id="save-skip-circle" class="save-deck-switch-knob"></span>
|
||||
</button>
|
||||
<input type="checkbox" id="save-skip-check" class="hidden" />
|
||||
</div>
|
||||
@@ -505,9 +584,18 @@
|
||||
setEl('i18n-hqDesc', 'hqDesc');
|
||||
setEl('i18n-notice1', 'exportNotice1');
|
||||
setEl('i18n-notice2', 'exportNotice2');
|
||||
setEl('i18n-btnDownload', 'downloadImage');
|
||||
setEl('btn-save-deck', 'saveToMyDeck', false);
|
||||
setEl('i18n-btnDownload', 'downloadImageLabel');
|
||||
setEl('btn-save-deck', 'saveToMyDeckLabel', false);
|
||||
setEl('btn-go-home', 'goHome', false);
|
||||
setEl('save-modal-title', 'savePublicTitle');
|
||||
setEl('save-modal-desc', 'savePublicDesc');
|
||||
setEl('save-public-label', 'savePublicLabel');
|
||||
setEl('save-public-hint', 'savePublicDefault');
|
||||
setEl('save-skip-label', 'saveSkipLabel');
|
||||
setEl('save-skip-hint', 'saveSkipHint');
|
||||
setEl('save-deck-cancel', 'cancel');
|
||||
setEl('save-deck-confirm', 'saveButton');
|
||||
setEl('loading-confirm-btn', 'okButton');
|
||||
|
||||
const deckInput = document.getElementById('deckName');
|
||||
if (deckInput) deckInput.placeholder = translations.deckNamePlaceholder || "";
|
||||
@@ -667,11 +755,14 @@
|
||||
|
||||
/**
|
||||
* 미리보기 영역 PNG 캡처
|
||||
* @param {{ highQuality?: boolean }} [options]
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function capturePreviewPng() {
|
||||
async function capturePreviewPng(options = {}) {
|
||||
const area = document.getElementById('resultArea');
|
||||
const hq = document.getElementById('highQuality').checked;
|
||||
const hq =
|
||||
options.highQuality ??
|
||||
document.getElementById('highQuality').checked;
|
||||
const originalStyle = area.getAttribute('style') || '';
|
||||
const gridElements = area.querySelectorAll('div[class*="grid"]');
|
||||
const originalGrids = Array.from(gridElements).map((el) => el.className);
|
||||
@@ -731,6 +822,47 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** 저장·다운로드 캡처와 동일한 미리보기 기준 너비(px) */
|
||||
const DECK_PREVIEW_SAVE_WIDTH = 1200;
|
||||
|
||||
/**
|
||||
* 저장용 미리보기를 기준 너비로 정규화
|
||||
* @param {string} dataUrl
|
||||
* @param {number} [targetWidth]
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function normalizePreviewForSave(
|
||||
dataUrl,
|
||||
targetWidth = DECK_PREVIEW_SAVE_WIDTH,
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (!img.width) {
|
||||
resolve(dataUrl);
|
||||
return;
|
||||
}
|
||||
const scale = targetWidth / img.width;
|
||||
const w = targetWidth;
|
||||
const h = Math.max(1, Math.round(img.height * scale));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
resolve(dataUrl);
|
||||
return;
|
||||
}
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.drawImage(img, 0, 0, w, h);
|
||||
resolve(canvas.toDataURL('image/png'));
|
||||
};
|
||||
img.onerror = () => reject(new Error('Preview resize failed'));
|
||||
img.src = dataUrl;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장·공유 로딩 UI
|
||||
* @param {string} message
|
||||
@@ -741,9 +873,21 @@
|
||||
const msgEl = document.getElementById('loading-message');
|
||||
const subEl = document.getElementById('loading-submessage');
|
||||
const fillEl = document.getElementById('loading-bar-fill');
|
||||
const progressWrap = document.getElementById('loading-progress-wrap');
|
||||
const confirmBtn = document.getElementById('loading-confirm-btn');
|
||||
let autoTimer = null;
|
||||
let current = 0;
|
||||
|
||||
const resetUi = () => {
|
||||
msgEl.classList.remove('is-success', 'is-error');
|
||||
progressWrap.classList.remove('hidden');
|
||||
confirmBtn.classList.add('hidden');
|
||||
confirmBtn.onclick = null;
|
||||
fillEl.style.width = '0%';
|
||||
subEl.textContent = '';
|
||||
current = 0;
|
||||
};
|
||||
|
||||
const stopAuto = () => {
|
||||
if (autoTimer) {
|
||||
clearInterval(autoTimer);
|
||||
@@ -766,12 +910,64 @@
|
||||
}, 120);
|
||||
};
|
||||
|
||||
resetUi();
|
||||
msgEl.textContent = message;
|
||||
subEl.textContent = '';
|
||||
fillEl.style.width = '0%';
|
||||
current = 0;
|
||||
screen.style.display = 'flex';
|
||||
|
||||
/**
|
||||
* 로딩 완료 → 결과 메시지 표시
|
||||
* @param {object} opts
|
||||
* @param {string} opts.title
|
||||
* @param {string} [opts.subtitle]
|
||||
* @param {boolean} [opts.redirectHome]
|
||||
* @param {number} [opts.redirectDelay]
|
||||
*/
|
||||
const finishSuccess = async ({
|
||||
title,
|
||||
subtitle = '',
|
||||
redirectHome = false,
|
||||
redirectDelay = 1400,
|
||||
}) => {
|
||||
stopAuto();
|
||||
setProgress(100);
|
||||
await new Promise((r) => setTimeout(r, 380));
|
||||
progressWrap.classList.add('hidden');
|
||||
msgEl.textContent = title;
|
||||
msgEl.classList.add('is-success');
|
||||
subEl.textContent = redirectHome
|
||||
? [subtitle, t('redirectingHome', '잠시 후 홈으로 이동합니다.')].filter(Boolean).join('\n')
|
||||
: subtitle;
|
||||
if (redirectHome) {
|
||||
await new Promise((r) => setTimeout(r, redirectDelay));
|
||||
screen.style.display = 'none';
|
||||
resetUi();
|
||||
goHome();
|
||||
return;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 700));
|
||||
screen.style.display = 'none';
|
||||
resetUi();
|
||||
};
|
||||
|
||||
/**
|
||||
* 오류 메시지 표시
|
||||
* @param {string} title
|
||||
* @param {string} [subtitle]
|
||||
*/
|
||||
const finishError = (title, subtitle = '') => {
|
||||
stopAuto();
|
||||
progressWrap.classList.add('hidden');
|
||||
fillEl.style.width = '100%';
|
||||
msgEl.textContent = title;
|
||||
msgEl.classList.add('is-error');
|
||||
subEl.textContent = subtitle;
|
||||
confirmBtn.classList.remove('hidden');
|
||||
confirmBtn.onclick = () => {
|
||||
screen.style.display = 'none';
|
||||
resetUi();
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
setProgress,
|
||||
setSubmessage: (text) => {
|
||||
@@ -779,15 +975,15 @@
|
||||
},
|
||||
startAuto,
|
||||
stopAuto,
|
||||
finishSuccess,
|
||||
finishError,
|
||||
complete: async (doneMessage) => {
|
||||
stopAuto();
|
||||
setProgress(100, doneMessage);
|
||||
await new Promise((r) => setTimeout(r, 450));
|
||||
screen.style.display = 'none';
|
||||
await finishSuccess({ title: doneMessage });
|
||||
},
|
||||
hide: () => {
|
||||
stopAuto();
|
||||
screen.style.display = 'none';
|
||||
resetUi();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -811,14 +1007,11 @@
|
||||
*/
|
||||
function setToggleState(toggleId, circleId, checkboxId, on) {
|
||||
const toggle = document.getElementById(toggleId);
|
||||
const circle = document.getElementById(circleId);
|
||||
const checkbox = document.getElementById(checkboxId);
|
||||
if (!toggle || !circle || !checkbox) return;
|
||||
if (!toggle || !checkbox) return;
|
||||
checkbox.checked = on;
|
||||
toggle.classList.toggle('bg-blue-600', on);
|
||||
toggle.classList.toggle('bg-slate-200', !on);
|
||||
circle.classList.toggle('translate-x-6', on);
|
||||
circle.classList.toggle('translate-x-1', !on);
|
||||
toggle.classList.toggle('is-on', on);
|
||||
toggle.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -906,20 +1099,26 @@
|
||||
*/
|
||||
async function executeDeckSave(isPublic) {
|
||||
const cards = getCardsPayload();
|
||||
if (!cards.length) {
|
||||
alert('저장할 카드가 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const loader = beginSaveLoading(
|
||||
t('savingToMyDeck', '내 덱으로 저장 중...'),
|
||||
);
|
||||
if (!cards.length) {
|
||||
loader.finishError(
|
||||
t('noCardsToSave', '저장할 카드가 없습니다.'),
|
||||
'',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
loader.setProgress(8, t('savingPreview', '미리보기 생성 중...'));
|
||||
loader.startAuto(52);
|
||||
let previewImage = null;
|
||||
try {
|
||||
previewImage = await capturePreviewPng();
|
||||
previewImage = await capturePreviewPng({ highQuality: false });
|
||||
if (previewImage) {
|
||||
previewImage = await normalizePreviewForSave(previewImage);
|
||||
}
|
||||
} catch (captureErr) {
|
||||
console.warn('Preview capture failed:', captureErr);
|
||||
}
|
||||
@@ -935,20 +1134,22 @@
|
||||
isPublic,
|
||||
});
|
||||
loader.stopAuto();
|
||||
const visibility = isPublic ? '공개' : '비공개';
|
||||
if (!previewImage) {
|
||||
await loader.complete(t('saveComplete', '저장 완료'));
|
||||
alert(
|
||||
`덱이 ${visibility}로 저장되었습니다.\n(미리보기 이미지 생성 실패)\n${deck.name}`,
|
||||
);
|
||||
} else {
|
||||
await loader.complete(t('saveComplete', '저장 완료'));
|
||||
alert(`덱이 ${visibility}로 저장되었습니다.\n${deck.name}`);
|
||||
}
|
||||
const subtitle = !previewImage
|
||||
? `${deck.name}\n${t('previewFailedNote', '(미리보기 이미지 생성에 실패했습니다)')}`
|
||||
: deck.name;
|
||||
await loader.finishSuccess({
|
||||
title: isPublic
|
||||
? t('deckSavedPublic', '덱이 공개로 저장되었습니다.')
|
||||
: t('deckSavedPrivate', '덱이 비공개로 저장되었습니다.'),
|
||||
subtitle,
|
||||
redirectHome: true,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
loader.hide();
|
||||
alert(e.message || '저장에 실패했습니다.');
|
||||
loader.finishError(
|
||||
t('saveFailedTitle', '저장에 실패했습니다.'),
|
||||
e.message || t('tryAgain', '다시 시도해 주세요.'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -982,11 +1183,16 @@
|
||||
link.download = `${fileNameStr}.png`;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
await loader.complete(t('downloadComplete', '다운로드 완료'));
|
||||
await loader.finishSuccess({
|
||||
title: t('downloadComplete', '다운로드 완료'),
|
||||
subtitle: `${fileNameStr}.png`,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Capture Error:', e);
|
||||
loader.hide();
|
||||
alert(translations.saveError || '이미지 저장 실패');
|
||||
loader.finishError(
|
||||
t('saveError', '이미지 저장 실패'),
|
||||
t('tryAgain', '다시 시도해 주세요.'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1011,6 +1217,7 @@
|
||||
window.location.href = lang && lang !== 'ko' ? `/?lang=${lang}` : '/';
|
||||
}
|
||||
</script>
|
||||
<script src="/script/page-i18n.js"></script>
|
||||
<script src="/script/auth-header.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
20
script/ads.js
Normal file
20
script/ads.js
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Google AdSense 슬롯 초기화
|
||||
*/
|
||||
(function initAds() {
|
||||
function pushAds() {
|
||||
document.querySelectorAll('ins.adsbygoogle').forEach(() => {
|
||||
try {
|
||||
(window.adsbygoogle = window.adsbygoogle || []).push({});
|
||||
} catch (err) {
|
||||
console.warn('AdSense push failed:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', pushAds);
|
||||
} else {
|
||||
pushAds();
|
||||
}
|
||||
})();
|
||||
@@ -50,6 +50,9 @@ const UaDeckApi = (() => {
|
||||
if (res.status === 405 || res.status === 404) {
|
||||
return 'API 서버에 연결되지 않았습니다. Docker 또는 npm run api:dev 로 API를 실행하세요.';
|
||||
}
|
||||
if (res.status === 413) {
|
||||
return data.error || '업로드 크기가 제한을 초과했습니다. 고화질 옵션을 끄고 다시 시도해 주세요.';
|
||||
}
|
||||
if (res.status === 0 || res.type === 'opaque') {
|
||||
return '네트워크 오류입니다. API 서버가 실행 중인지 확인하세요.';
|
||||
}
|
||||
|
||||
@@ -11,6 +11,18 @@
|
||||
let currentUser = null;
|
||||
let dropdownOpen = false;
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {string} fallback
|
||||
* @returns {string}
|
||||
*/
|
||||
function t(key, fallback) {
|
||||
if (window.UaPageI18n?.t) {
|
||||
return window.UaPageI18n.t(key, fallback);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 이스케이프
|
||||
* @param {string} str
|
||||
@@ -51,6 +63,16 @@
|
||||
return src;
|
||||
}
|
||||
|
||||
/**
|
||||
* 표시용 닉네임
|
||||
* @param {object|null} user
|
||||
* @returns {string}
|
||||
*/
|
||||
function getDisplayName(user) {
|
||||
if (!user) return '';
|
||||
return String(user.username || user.displayName || '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 아바타 HTML
|
||||
* @param {object|null} user
|
||||
@@ -62,18 +84,19 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로필 버튼 HTML (아바타 + 사용자명)
|
||||
* 프로필 버튼 HTML (아바타 + 닉네임)
|
||||
* @param {object|null} user
|
||||
* @returns {string}
|
||||
*/
|
||||
function profileBtnHtml(user) {
|
||||
const name = getDisplayName(user);
|
||||
const avatar = avatarHtml(user);
|
||||
if (!user?.username) {
|
||||
return `<span class="auth-avatar-wrap">${avatar}</span>`;
|
||||
if (!name) {
|
||||
return `<span class="auth-avatar-circle">${avatar}</span>`;
|
||||
}
|
||||
return `
|
||||
<span class="auth-avatar-wrap">${avatar}</span>
|
||||
<span class="auth-username">${escapeHtml(user.username)}</span>
|
||||
<span class="auth-avatar-circle">${avatar}</span>
|
||||
<span class="auth-username">${escapeHtml(name)}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -86,28 +109,28 @@
|
||||
|
||||
if (!currentUser) {
|
||||
dd.innerHTML = `
|
||||
<a href="/login.html" class="auth-dropdown-item">로그인</a>
|
||||
<a href="/signup.html" class="auth-dropdown-item">회원가입</a>
|
||||
<a href="/login.html" class="auth-dropdown-item">${escapeHtml(t('authLogin', '로그인'))}</a>
|
||||
<a href="/signup.html" class="auth-dropdown-item">${escapeHtml(t('authSignup', '회원가입'))}</a>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const adminLink =
|
||||
currentUser.role === 'admin'
|
||||
? `<a href="/admin.html" class="auth-dropdown-item">관리자</a>`
|
||||
? `<a href="/admin.html" class="auth-dropdown-item">${escapeHtml(t('authAdmin', '관리자'))}</a>`
|
||||
: '';
|
||||
|
||||
dd.innerHTML = `
|
||||
<div class="auth-dropdown-header">
|
||||
<div class="auth-dropdown-name">${currentUser.username}</div>
|
||||
<div class="auth-dropdown-email">${currentUser.email || ''}</div>
|
||||
<div class="auth-dropdown-name">${escapeHtml(getDisplayName(currentUser))}</div>
|
||||
<div class="auth-dropdown-email">${escapeHtml(currentUser.email || '')}</div>
|
||||
</div>
|
||||
<a href="/profile.html" class="auth-dropdown-item">내 프로필</a>
|
||||
<a href="/deckHistory.html" class="auth-dropdown-item">내 덱 관리</a>
|
||||
<a href="/preset.html" class="auth-dropdown-item">공개 덱</a>
|
||||
<a href="/profile.html" class="auth-dropdown-item">${escapeHtml(t('authMyProfile', '내 프로필'))}</a>
|
||||
<a href="/deckHistory.html" class="auth-dropdown-item">${escapeHtml(t('authMyDecks', '내 덱 관리'))}</a>
|
||||
<a href="/preset.html" class="auth-dropdown-item">${escapeHtml(t('authPublicDecks', '공개 덱'))}</a>
|
||||
${adminLink}
|
||||
<div class="auth-dropdown-divider"></div>
|
||||
<button type="button" class="auth-dropdown-item danger" id="auth-logout-btn">로그아웃</button>
|
||||
<button type="button" class="auth-dropdown-item danger" id="auth-logout-btn">${escapeHtml(t('authLogout', '로그아웃'))}</button>
|
||||
`;
|
||||
|
||||
const logoutBtn = document.getElementById('auth-logout-btn');
|
||||
@@ -127,8 +150,14 @@
|
||||
function renderHeader() {
|
||||
const btn = document.getElementById('auth-avatar-btn');
|
||||
if (btn) {
|
||||
const name = getDisplayName(currentUser);
|
||||
btn.innerHTML = profileBtnHtml(currentUser);
|
||||
btn.classList.toggle('auth-profile-btn--named', Boolean(currentUser?.username));
|
||||
btn.classList.toggle('auth-profile-trigger--named', Boolean(name));
|
||||
btn.classList.toggle('auth-profile-trigger--guest', !name);
|
||||
const menuLabel = name
|
||||
? t('authAccountMenuNamed', '{name} 계정 메뉴').replace('{name}', name)
|
||||
: t('authAccountMenu', '계정 메뉴');
|
||||
btn.setAttribute('aria-label', menuLabel);
|
||||
}
|
||||
renderDropdown();
|
||||
}
|
||||
@@ -140,7 +169,7 @@
|
||||
function mount(slot) {
|
||||
slot.innerHTML = `
|
||||
<div class="auth-header-wrap">
|
||||
<button type="button" id="auth-avatar-btn" class="auth-avatar-btn" aria-label="계정 메뉴">
|
||||
<button type="button" id="auth-avatar-btn" class="auth-profile-trigger auth-profile-trigger--guest" aria-label="${escapeHtml(t('authAccountMenu', '계정 메뉴'))}">
|
||||
${profileBtnHtml(currentUser)}
|
||||
</button>
|
||||
<div id="auth-dropdown" class="auth-dropdown"></div>
|
||||
@@ -185,6 +214,10 @@
|
||||
* 초기화
|
||||
*/
|
||||
async function init() {
|
||||
if (window.UaPageI18n?.applyPageI18n) {
|
||||
await window.UaPageI18n.applyPageI18n();
|
||||
}
|
||||
|
||||
currentUser = window.UaDeckApi.getCachedUser();
|
||||
await refreshCurrentUser();
|
||||
|
||||
|
||||
60
script/deck-favorites.js
Normal file
60
script/deck-favorites.js
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 덱 즐겨찾기 (localStorage, share_code 기준)
|
||||
*/
|
||||
(function initDeckFavorites() {
|
||||
const STORAGE_KEY = 'uadeck.deckFavorites';
|
||||
|
||||
/**
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
function readSet() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
const list = raw ? JSON.parse(raw) : [];
|
||||
return new Set(Array.isArray(list) ? list : []);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Set<string>} set
|
||||
*/
|
||||
function writeSet(set) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify([...set]));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} shareCode
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isFavorite(shareCode) {
|
||||
if (!shareCode) return false;
|
||||
return readSet().has(shareCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} shareCode
|
||||
* @returns {boolean} 토글 후 즐겨찾기 여부
|
||||
*/
|
||||
function toggle(shareCode) {
|
||||
if (!shareCode) return false;
|
||||
const set = readSet();
|
||||
if (set.has(shareCode)) {
|
||||
set.delete(shareCode);
|
||||
} else {
|
||||
set.add(shareCode);
|
||||
}
|
||||
writeSet(set);
|
||||
return set.has(shareCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function getAll() {
|
||||
return [...readSet()];
|
||||
}
|
||||
|
||||
window.UaDeckFavorites = { isFavorite, toggle, getAll };
|
||||
})();
|
||||
179
script/deck-preview-hold.js
Normal file
179
script/deck-preview-hold.js
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* 썸네일 미리보기 확대 (클릭 고정 / 길게 누르기)
|
||||
*/
|
||||
(function initDeckPreviewHold() {
|
||||
/** @type {HTMLElement|null} */
|
||||
let overlay = null;
|
||||
|
||||
/** @type {number} */
|
||||
let pressStartAt = 0;
|
||||
|
||||
/** @type {boolean} */
|
||||
let suppressClick = false;
|
||||
|
||||
/** @type {number} */
|
||||
const HOLD_THRESHOLD_MS = 280;
|
||||
|
||||
/**
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
function ensureOverlay() {
|
||||
if (!overlay) {
|
||||
overlay = document.createElement('div');
|
||||
overlay.className = 'deck-preview-hold-overlay hidden';
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-modal', 'true');
|
||||
overlay.innerHTML =
|
||||
'<div class="deck-preview-hold-box"><img alt="" draggable="false" /></div>';
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) hide();
|
||||
});
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
return overlay;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isVisible() {
|
||||
return Boolean(overlay && !overlay.classList.contains('hidden'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} src
|
||||
*/
|
||||
function show(src) {
|
||||
if (!src) return;
|
||||
const el = ensureOverlay();
|
||||
const img = el.querySelector('img');
|
||||
if (img) img.src = src;
|
||||
el.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hide() {
|
||||
if (overlay) overlay.classList.add('hidden');
|
||||
pressStartAt = 0;
|
||||
suppressClick = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} e
|
||||
* @returns {HTMLElement|null}
|
||||
*/
|
||||
function findHoldTarget(e) {
|
||||
const target = e.target;
|
||||
if (!(target instanceof Element)) return null;
|
||||
return target.closest('[data-hold-preview]');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} hold
|
||||
* @returns {string}
|
||||
*/
|
||||
function getPreviewSrc(hold) {
|
||||
return hold.dataset.previewSrc || '';
|
||||
}
|
||||
|
||||
function initGlobal() {
|
||||
if (document.documentElement.dataset.deckHoldInit === '1') return;
|
||||
document.documentElement.dataset.deckHoldInit = '1';
|
||||
|
||||
document.addEventListener(
|
||||
'pointerdown',
|
||||
(e) => {
|
||||
const hold = findHoldTarget(e);
|
||||
if (!hold) return;
|
||||
const src = getPreviewSrc(hold);
|
||||
if (!src) return;
|
||||
|
||||
pressStartAt = Date.now();
|
||||
show(src);
|
||||
|
||||
if (hold.setPointerCapture && e.pointerId !== undefined) {
|
||||
try {
|
||||
hold.setPointerCapture(e.pointerId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
document.addEventListener(
|
||||
'pointerup',
|
||||
(e) => {
|
||||
if (!pressStartAt) return;
|
||||
|
||||
const hold = findHoldTarget(e);
|
||||
if (hold?.releasePointerCapture && e.pointerId !== undefined) {
|
||||
try {
|
||||
hold.releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
const heldMs = Date.now() - pressStartAt;
|
||||
pressStartAt = 0;
|
||||
|
||||
if (heldMs >= HOLD_THRESHOLD_MS) {
|
||||
hide();
|
||||
suppressClick = true;
|
||||
window.setTimeout(() => {
|
||||
suppressClick = false;
|
||||
}, 0);
|
||||
} else {
|
||||
suppressClick = true;
|
||||
window.setTimeout(() => {
|
||||
suppressClick = false;
|
||||
}, 400);
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
document.addEventListener(
|
||||
'pointercancel',
|
||||
() => {
|
||||
hide();
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (suppressClick) return;
|
||||
|
||||
const hold = findHoldTarget(e);
|
||||
if (hold) {
|
||||
const src = getPreviewSrc(hold);
|
||||
if (!src) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isVisible()) hide();
|
||||
else show(src);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isVisible() && e.target === overlay) {
|
||||
hide();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') hide();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} _container
|
||||
*/
|
||||
function bindHoldPreview(_container) {
|
||||
initGlobal();
|
||||
}
|
||||
|
||||
initGlobal();
|
||||
|
||||
window.UaDeckPreviewHold = { bindHoldPreview, hide };
|
||||
})();
|
||||
96
script/page-help.js
Normal file
96
script/page-help.js
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 페이지 제목 옆 ? 버튼 — 설명 툴팁
|
||||
*/
|
||||
(function initPageHelp() {
|
||||
/** @type {HTMLElement|null} */
|
||||
let activeTooltip = null;
|
||||
|
||||
/** @type {HTMLElement|null} */
|
||||
let activeBtn = null;
|
||||
|
||||
function closeTooltip() {
|
||||
if (activeTooltip) {
|
||||
activeTooltip.remove();
|
||||
activeTooltip = null;
|
||||
}
|
||||
if (activeBtn) {
|
||||
activeBtn.setAttribute('aria-expanded', 'false');
|
||||
activeBtn = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} btn
|
||||
* @param {string} text
|
||||
*/
|
||||
function openTooltip(btn, text) {
|
||||
if (!text) return;
|
||||
closeTooltip();
|
||||
|
||||
const tip = document.createElement('div');
|
||||
tip.className = 'page-help-tooltip';
|
||||
tip.setAttribute('role', 'tooltip');
|
||||
tip.id = `page-help-tip-${Date.now()}`;
|
||||
tip.textContent = text;
|
||||
document.body.appendChild(tip);
|
||||
btn.setAttribute('aria-expanded', 'true');
|
||||
btn.setAttribute('aria-describedby', tip.id);
|
||||
|
||||
const rect = btn.getBoundingClientRect();
|
||||
const margin = 12;
|
||||
let left = rect.left + window.scrollX;
|
||||
const top = rect.bottom + window.scrollY + 8;
|
||||
const tipWidth = tip.offsetWidth;
|
||||
|
||||
if (left + tipWidth > window.innerWidth - margin) {
|
||||
left = window.innerWidth - tipWidth - margin;
|
||||
}
|
||||
left = Math.max(margin, left);
|
||||
|
||||
tip.style.top = `${top}px`;
|
||||
tip.style.left = `${left}px`;
|
||||
|
||||
activeTooltip = tip;
|
||||
activeBtn = btn;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} [translations]
|
||||
*/
|
||||
function bindPageHelp(translations) {
|
||||
const tFn = (key, fallback) => {
|
||||
if (translations?.[key]) return translations[key];
|
||||
if (window.UaPageI18n?.t) return window.UaPageI18n.t(key, fallback);
|
||||
return fallback;
|
||||
};
|
||||
|
||||
document.querySelectorAll('[data-i18n-tooltip]').forEach((btn) => {
|
||||
const key = btn.dataset.i18nTooltip;
|
||||
const text = tFn(key, '');
|
||||
const helpLabel = tFn('pageHelpLabel', '페이지 안내');
|
||||
btn.setAttribute('aria-label', text ? `${helpLabel}` : helpLabel);
|
||||
|
||||
if (btn.dataset.helpBound === '1') return;
|
||||
btn.dataset.helpBound = '1';
|
||||
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const current = tFn(key, '');
|
||||
if (activeBtn === btn && activeTooltip) {
|
||||
closeTooltip();
|
||||
return;
|
||||
}
|
||||
openTooltip(btn, current);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('click', closeTooltip);
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeTooltip();
|
||||
});
|
||||
window.addEventListener('scroll', closeTooltip, { passive: true });
|
||||
window.addEventListener('resize', closeTooltip);
|
||||
|
||||
window.UaPageHelp = { bindPageHelp, closeTooltip };
|
||||
})();
|
||||
72
script/page-i18n.js
Normal file
72
script/page-i18n.js
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* data-i18n 속성 기반 페이지 번역
|
||||
*/
|
||||
(function initPageI18n() {
|
||||
/**
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
function getLang() {
|
||||
const fromQuery = new URLSearchParams(window.location.search).get('lang');
|
||||
if (fromQuery) return fromQuery;
|
||||
return localStorage.getItem('lang') || 'ko';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} lang
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
async function loadTranslations(lang) {
|
||||
try {
|
||||
const res = await fetch(`/i18n/translations.${lang}.json`);
|
||||
if (!res.ok) return {};
|
||||
return await res.json();
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
let cachedTranslations = {};
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {string} [fallback]
|
||||
* @returns {string}
|
||||
*/
|
||||
function t(key, fallback = '') {
|
||||
return cachedTranslations[key] || fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} translations
|
||||
*/
|
||||
function applyTranslations(translations) {
|
||||
document.querySelectorAll('[data-i18n]').forEach((el) => {
|
||||
const key = el.dataset.i18n;
|
||||
if (translations[key]) el.textContent = translations[key];
|
||||
});
|
||||
document.querySelectorAll('[data-i18n-html]').forEach((el) => {
|
||||
const key = el.dataset.i18nHtml;
|
||||
if (translations[key]) el.innerHTML = translations[key];
|
||||
});
|
||||
document.querySelectorAll('[data-i18n-placeholder]').forEach((el) => {
|
||||
const key = el.dataset.i18nPlaceholder;
|
||||
if (translations[key]) el.placeholder = translations[key];
|
||||
});
|
||||
if (window.UaPageHelp?.bindPageHelp) {
|
||||
window.UaPageHelp.bindPageHelp(translations);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyPageI18n() {
|
||||
const lang = await getLang();
|
||||
cachedTranslations = await loadTranslations(lang);
|
||||
applyTranslations(cachedTranslations);
|
||||
return cachedTranslations;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
applyPageI18n();
|
||||
});
|
||||
|
||||
window.UaPageI18n = { applyPageI18n, getLang, loadTranslations, t };
|
||||
})();
|
||||
36
signup.html
36
signup.html
@@ -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');
|
||||
|
||||
@@ -43,7 +43,6 @@
|
||||
.admin-main {
|
||||
max-width: 80rem;
|
||||
margin: 0 auto;
|
||||
padding: 3rem 1.5rem 5rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user