v4.0.0: 덱 UX·i18n·썸네일 저장 개선 및 413 수정
즐겨찾기·홀드 미리보기·페이지 도움말·3개국어 번역을 추가하고, 덱 저장 시 썸네일을 1200px로 통일하며 Nginx/API 업로드 제한을 상향했다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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);
|
||||
|
||||
Reference in New Issue
Block a user