덱 목록 무한 스크롤과 WebP 썸네일 최적화 추가
- 내 덱·공개 덱 목록을 무한 스크롤로 전환(공용 페이저 모듈 추가) - 즐겨찾기 토글 시 카드만 in-place 갱신해 스크롤 위치 유지 - 저장 시 420px WebP 썸네일 생성·전송, 목록은 썸네일 우선 사용 - 삭제·미참조 정리에서 원본 PNG와 썸네일 함께 처리 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
127
script/deck-list-pager.js
Normal file
127
script/deck-list-pager.js
Normal 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 };
|
||||
})();
|
||||
Reference in New Issue
Block a user