Files
uniare.sori.studio/deckHistory.html
zenn a90bdcbe13 덱 목록 무한 스크롤과 WebP 썸네일 최적화 추가
- 내 덱·공개 덱 목록을 무한 스크롤로 전환(공용 페이저 모듈 추가)
- 즐겨찾기 토글 시 카드만 in-place 갱신해 스크롤 위치 유지
- 저장 시 420px WebP 썸네일 생성·전송, 목록은 썸네일 우선 사용
- 삭제·미참조 정리에서 원본 PNG와 썸네일 함께 처리

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 18:17:58 +09:00

593 lines
28 KiB
HTML

<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-4516420168710424" crossorigin="anonymous"></script>
<link rel="stylesheet" href="./style/output.css?v=4.0.8" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800;900&family=Noto+Sans+KR:wght@400;700;900&display=swap" rel="stylesheet" />
<title>내 덱 관리 - UNIARE</title>
</head>
<body class="app-page deck-history-page">
<nav class="app-nav">
<div class="app-nav-inner">
<a href="/" class="app-brand">
<img src="/images/assets/uniare-h48.png" alt="UNIARE" class="app-brand-logo" height="32" />
</a>
<div id="auth-header-slot"></div>
</div>
</nav>
<div class="app-ad-top">
<div class="app-ad-slot app-ad-slot--leaderboard" id="ad-leaderboard" aria-label="광고"></div>
</div>
<div class="app-shell">
<div class="app-body-layout">
<main class="app-main deck-history-main">
<header class="deck-history-header">
<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">
<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" 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">
<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?v=4.0.8"></script>
<script src="/script/ads.js?v=4.0.8"></script>
<script src="/script/deck-favorites.js?v=4.0.8"></script>
<script src="/script/deck-favorites-shim.js?v=4.0.8"></script>
<script src="/script/deck-preview-hold.js?v=4.0.8"></script>
<script src="/script/deck-list-pager.js?v=4.0.19"></script>
<script src="/script/page-i18n.js?v=4.0.8"></script>
<script src="/script/page-help.js?v=4.0.8"></script>
<script src="/script/auth-header.js?v=4.0.8"></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
* @returns {string}
*/
function escapeHtml(str) {
return String(str ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/**
* 덱 표시 이름
* @param {object} deck
* @returns {string}
*/
function getDeckTitle(deck) {
return deck.name || deck.series_name || deck.series || t('deckUntitled', '이름 없음');
}
/**
* 시리즈 필터 옵션 갱신
* @param {Array<object>} decks
*/
function populateSeriesFilter(decks) {
const select = document.getElementById('deck-series-filter');
const current = select.value;
const seriesMap = new Map();
decks.forEach((d) => {
if (!seriesMap.has(d.series)) {
seriesMap.set(d.series, d.series_name || d.series);
}
});
const sorted = [...seriesMap.entries()].sort((a, b) =>
String(a[1]).localeCompare(String(b[1]), 'ko'),
);
select.innerHTML =
`<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;
}
}
/**
* 검색·필터 적용
* @returns {Array<object>}
*/
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 = [
getDeckTitle(d),
d.series_name,
d.series,
d.share_code,
]
.filter(Boolean)
.join(' ')
.toLowerCase();
return haystack.includes(query);
});
}
/**
* 즐겨찾기 개수
* @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>`;
}
/**
* 미디어 URL에 갱신 시각 기반 캐시 무효화 쿼리를 붙인다.
* @param {string|null} apiPath
* @param {string|null} updatedAt
* @returns {string|null}
*/
function withVersion(apiPath, updatedAt) {
if (!apiPath) return null;
const src = UaDeckApi.resolveMediaUrl(apiPath);
const version = updatedAt ? `?v=${encodeURIComponent(updatedAt)}` : '';
return src + version;
}
/**
* 목록 표시용 이미지(썸네일 우선)
* @param {object} d
* @returns {string|null}
*/
function getThumbSrc(d) {
return withVersion(d.thumbnail_url || d.preview_url, d.updated_at);
}
/**
* 확대 표시용 원본 이미지(원본 우선)
* @param {object} d
* @returns {string|null}
*/
function getFullSrc(d) {
return withVersion(d.preview_url || d.thumbnail_url, d.updated_at);
}
/**
* 미리보기 HTML
* @param {object} d
* @param {string} title
* @returns {string}
*/
function renderPreviewHtml(d, title) {
const thumbSrc = getThumbSrc(d);
if (!thumbSrc) {
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 fullSrc = getFullSrc(d) || thumbSrc;
const holdAttrs = `data-hold-preview data-preview-src="${escapeHtml(fullSrc)}"`;
return `<div class="deck-card-preview deck-card-preview--holdable" ${holdAttrs}><img src="${escapeHtml(thumbSrc)}" alt="${escapeHtml(title)} 미리보기" loading="lazy" class="deck-card-preview-img" draggable="false" /></div>`;
}
/**
* 덱 카드 HTML
* @param {object} d
* @returns {string}
*/
function renderDeckCard(d) {
const title = escapeHtml(getDeckTitle(d));
const seriesLabel = escapeHtml(d.series_name || d.series);
const date = new Date(d.updated_at).toLocaleDateString('ko-KR');
const isPublic = Boolean(d.is_public);
const visibilityBadge = isPublic
? `<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="${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">${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-rename="${escapeHtml(d.share_code)}" data-current-name="${escapeHtml(getDeckTitle(d))}">${escapeHtml(t('deckRename', '이름 변경'))}</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');
});
}
/** @type {{ refresh: (items: object[]) => void }|null} */
let deckPager = null;
/**
* 현재 필터 상태의 빈 목록 메시지
* @returns {string}
*/
function emptyMessage() {
const favOnly =
document.getElementById('deck-favorite-filter')?.getAttribute('aria-pressed') === 'true';
return favOnly
? t('deckNoFavorites', '즐겨찾기한 덱이 없습니다.')
: t('deckNoMatch', '조건에 맞는 덱이 없습니다.');
}
/**
* 이미지 로드 실패 시 미리보기 없음 표시로 교체 (append 되는 카드까지 위임 처리)
*/
function bindPreviewErrorHandler() {
const list = document.getElementById('deck-list');
if (!list || list.dataset.previewErrBound === '1') return;
list.dataset.previewErrBound = '1';
list.addEventListener(
'error',
(e) => {
const img = e.target;
if (!(img instanceof HTMLImageElement) || !img.classList.contains('deck-card-preview-img')) {
return;
}
const wrap = img.closest('.deck-card-preview');
if (wrap) {
wrap.classList.remove('deck-card-preview--holdable');
wrap.removeAttribute('data-hold-preview');
wrap.removeAttribute('data-preview-src');
wrap.innerHTML = noPreviewHtml();
}
},
true,
);
}
/**
* 즐겨찾기 상태를 카드 DOM에 즉시 반영 (전체 리렌더 없이 스크롤 유지)
* @param {string} shareCode
* @param {boolean} on
*/
function applyFavoriteState(shareCode, on) {
const article = document.querySelector(`#deck-list .deck-card[data-code="${CSS.escape(shareCode)}"]`);
if (article) article.classList.toggle('is-favorite', on);
const btn = document.querySelector(`#deck-list [data-favorite="${CSS.escape(shareCode)}"]`);
if (btn) {
btn.classList.toggle('is-on', on);
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
const star = btn.querySelector('.deck-star-icon');
if (star) star.classList.toggle('is-on', on);
}
}
/**
* 목록 렌더링
*/
function renderDeckList() {
const list = document.getElementById('deck-list');
const filtered = getFilteredDecks();
document.getElementById('deck-count').textContent = formatDeckCount(filtered.length, allDecks.length);
updateFavCount();
if (!deckPager) {
bindPreviewErrorHandler();
deckPager = UaDeckPager.create({
container: list,
renderItem: renderDeckCard,
emptyHtml: () =>
`<div class="deck-grid"><div class="deck-grid-empty">${escapeHtml(emptyMessage())}</div></div>`,
});
}
deckPager.refresh(filtered);
closeAllDeckMenus();
}
/**
* 덱 목록 API 로드
*/
async function loadDecks() {
const list = document.getElementById('deck-list');
const toolbar = document.getElementById('deck-toolbar');
if (!UaDeckApi.getToken()) {
location.href = '/login.html?redirect=' + encodeURIComponent(location.pathname);
return;
}
try {
const { decks } = await UaDeckApi.getMyDecks();
allDecks = decks;
if (!decks.length) {
toolbar.classList.add('hidden');
list.innerHTML =
`<div class="deck-status-message">${escapeHtml(t('deckEmpty', '저장된 덱이 없습니다.'))}</div>`;
return;
}
toolbar.classList.remove('hidden');
populateSeriesFilter(decks);
renderDeckList();
} catch (err) {
toolbar.classList.add('hidden');
list.innerHTML = `<div class="deck-status-message error">${escapeHtml(err.message)}</div>`;
}
}
document.addEventListener('DOMContentLoaded', async () => {
if (window.UaPageI18n?.applyPageI18n) {
await window.UaPageI18n.applyPageI18n();
}
if (typeof UaDeckFavorites?.init === 'function') {
await UaDeckFavorites.init();
}
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) {
try {
const code = favBtn.dataset.favorite;
const nowFav = await UaDeckFavorites.toggle(code);
const favOnly =
document.getElementById('deck-favorite-filter')?.getAttribute('aria-pressed') === 'true';
if (favOnly) {
renderDeckList();
} else {
applyFavoriteState(code, nowFav);
updateFavCount();
}
} catch (err) {
alert(err.message || t('deckFavoriteFailed', '즐겨찾기 변경에 실패했습니다.'));
}
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}`;
return;
}
const copyBtn = e.target.closest('[data-copy]');
if (copyBtn) {
const url = `${location.origin}/d/${copyBtn.dataset.copy}`;
await navigator.clipboard.writeText(url);
closeAllDeckMenus();
alert(t('deckLinkCopied', '공유 링크가 복사되었습니다.'));
return;
}
const renameBtn = e.target.closest('[data-rename]');
if (renameBtn) {
const code = renameBtn.dataset.rename;
const current = renameBtn.dataset.currentName || '';
const newName = prompt(t('deckRenamePrompt', '새 덱 이름을 입력하세요'), current);
if (newName === null) return;
const trimmed = newName.trim();
if (!trimmed) {
alert(t('deckRenameEmpty', '이름을 입력해 주세요.'));
return;
}
if (trimmed === current) {
closeAllDeckMenus();
return;
}
try {
await UaDeckApi.updateDeck(code, { name: trimmed });
const deck = allDecks.find((d) => d.share_code === code);
if (deck) deck.name = trimmed;
closeAllDeckMenus();
renderDeckList();
} catch (err) {
alert(err.message || t('deckRenameFailed', '이름 변경에 실패했습니다.'));
}
return;
}
const toggleBtn = e.target.closest('[data-toggle]');
if (toggleBtn) {
const code = toggleBtn.dataset.toggle;
const nextPublic = toggleBtn.dataset.public !== '1';
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) {
closeAllDeckMenus();
if (!confirm(t('deckDeleteConfirm', '이 덱을 삭제하시겠습니까?'))) return;
await UaDeckApi.deleteDeck(deleteBtn.dataset.delete);
UaDeckFavorites.forget(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">${escapeHtml(t('deckEmpty', '저장된 덱이 없습니다.'))}</div>`;
return;
}
populateSeriesFilter(allDecks);
renderDeckList();
}
});
});
</script>
</body>
</html>