덱 목록 무한 스크롤과 WebP 썸네일 최적화 추가
- 내 덱·공개 덱 목록을 무한 스크롤로 전환(공용 페이저 모듈 추가) - 즐겨찾기 토글 시 카드만 in-place 갱신해 스크롤 위치 유지 - 저장 시 420px WebP 썸네일 생성·전송, 목록은 썸네일 우선 사용 - 삭제·미참조 정리에서 원본 PNG와 썸네일 함께 처리 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
148
deckHistory.html
148
deckHistory.html
@@ -81,6 +81,7 @@
|
||||
<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>
|
||||
@@ -230,13 +231,37 @@
|
||||
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)}` : '';
|
||||
/**
|
||||
* 미디어 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
|
||||
@@ -244,12 +269,13 @@
|
||||
* @returns {string}
|
||||
*/
|
||||
function renderPreviewHtml(d, title) {
|
||||
const previewSrc = getPreviewSrc(d);
|
||||
if (!previewSrc) {
|
||||
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 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>`;
|
||||
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>`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -311,34 +337,35 @@
|
||||
});
|
||||
}
|
||||
|
||||
/** @type {{ refresh: (items: object[]) => void }|null} */
|
||||
let deckPager = null;
|
||||
|
||||
/**
|
||||
* 목록 렌더링
|
||||
* 현재 필터 상태의 빈 목록 메시지
|
||||
* @returns {string}
|
||||
*/
|
||||
function renderDeckList() {
|
||||
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');
|
||||
const countEl = document.getElementById('deck-count');
|
||||
const filtered = getFilteredDecks();
|
||||
|
||||
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">${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', () => {
|
||||
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');
|
||||
@@ -346,8 +373,49 @@
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -424,8 +492,16 @@
|
||||
const favBtn = e.target.closest('[data-favorite]');
|
||||
if (favBtn) {
|
||||
try {
|
||||
await UaDeckFavorites.toggle(favBtn.dataset.favorite);
|
||||
renderDeckList();
|
||||
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', '즐겨찾기 변경에 실패했습니다.'));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user