덱 목록 무한 스크롤과 WebP 썸네일 최적화 추가

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-02 18:17:58 +09:00
parent 63aabf61c4
commit a90bdcbe13
15 changed files with 516 additions and 85 deletions

127
script/deck-list-pager.js Normal file
View File

@@ -0,0 +1,127 @@
/**
* 덱 목록 무한 스크롤 페이저
* 필터링된 배열을 화면에 청크 단위로 나눠 렌더링하고,
* 하단 센티넬이 뷰포트에 근접하면 다음 청크를 이어 붙인다.
*/
(function initDeckListPager() {
const DEFAULT_PAGE_SIZE = 24;
/**
* @typedef {object} DeckPagerOptions
* @property {HTMLElement} container 목록을 그릴 컨테이너
* @property {(item: object) => string} renderItem 카드 HTML 생성 함수
* @property {() => string} [emptyHtml] 항목이 없을 때 표시할 HTML
* @property {(grid: HTMLElement) => void} [onAppend] 청크 추가 후 콜백
* @property {string} [gridClass] 그리드 컨테이너 클래스
* @property {number} [pageSize] 청크당 항목 수
*/
/**
* 무한 스크롤 페이저 생성
* @param {DeckPagerOptions} options
* @returns {{ refresh: (items: object[]) => void }}
*/
function create(options) {
const {
container,
renderItem,
emptyHtml,
onAppend,
gridClass = 'deck-grid',
pageSize = DEFAULT_PAGE_SIZE,
} = options;
/** @type {object[]} */
let items = [];
let rendered = 0;
/** @type {HTMLElement|null} */
let grid = null;
/** @type {HTMLElement|null} */
let sentinel = null;
/** @type {IntersectionObserver|null} */
let observer = null;
function teardownObserver() {
if (observer) {
observer.disconnect();
observer = null;
}
}
function removeSentinel() {
teardownObserver();
if (sentinel) {
sentinel.remove();
sentinel = null;
}
}
function appendNext() {
if (!grid) return;
const next = items.slice(rendered, rendered + pageSize);
if (next.length) {
grid.insertAdjacentHTML('beforeend', next.map(renderItem).join(''));
rendered += next.length;
if (typeof onAppend === 'function') onAppend(grid);
}
if (rendered >= items.length) {
removeSentinel();
}
}
/**
* 목록을 처음부터 다시 렌더링
* @param {object[]} nextItems
*/
function refresh(nextItems) {
items = Array.isArray(nextItems) ? nextItems : [];
rendered = 0;
removeSentinel();
container.innerHTML = '';
if (!items.length) {
container.innerHTML = typeof emptyHtml === 'function' ? emptyHtml() : '';
grid = null;
return;
}
grid = document.createElement('div');
grid.className = gridClass;
container.appendChild(grid);
sentinel = document.createElement('div');
sentinel.setAttribute('aria-hidden', 'true');
sentinel.style.cssText = 'width:100%;height:1px;';
container.appendChild(sentinel);
appendNext();
if (rendered >= items.length) {
removeSentinel();
return;
}
if ('IntersectionObserver' in window) {
observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
appendNext();
break;
}
}
},
{ rootMargin: '600px 0px' },
);
observer.observe(sentinel);
} else {
// IntersectionObserver 미지원 환경은 전체 렌더로 폴백
while (rendered < items.length) appendNext();
}
}
return { refresh };
}
window.UaDeckPager = { create };
})();