Files
taekyoungc 7e958a7ac9 feat: 리스트 페이징을 lazy loading(무한 스크롤)으로 표준화
공용 CI4 pager 템플릿(components/pager.php)을 페이지 번호 대신
무한 스크롤 방식으로 교체. 화면 하단 도달 시(IntersectionObserver) 다음
페이지 HTML을 받아 리스트 표에 이어붙이고, '더보기' 버튼을 폴백으로 둔다.
Config\Pager의 default 템플릿이라 전체 리스트 화면에 일괄 적용된다.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 16:02:20 +09:00

110 lines
4.7 KiB
PHP

<?php
/**
* 공용 리스트 페이저 → Lazy Loading(무한 스크롤) 표준 컴포넌트.
*
* CI4 Pager 템플릿(Config\Pager: default_full/default_simple)으로 등록돼 있어,
* 모든 리스트 화면의 `<?= $pager->links() ?>` 호출이 이 컴포넌트를 렌더한다.
* 페이지 번호 대신 센티넬 + '더보기' 버튼을 두고, 화면 하단 도달 시(IntersectionObserver)
* 다음 페이지 HTML을 받아 바로 위 리스트 표(tbody)에 행을 이어붙인다.
*
* @var \CodeIgniter\Pager\PagerRenderer $pager
*/
$hasNext = $pager->hasNextPage();
$nextUrl = $hasNext ? (string) $pager->getNextPage() : '';
$curPage = (int) $pager->getCurrent();
$pageCount = (int) $pager->getPageCount();
?>
<?php if ($pageCount > 1): ?>
<div class="lazy-pager no-print mt-3 mb-2 flex flex-col items-center gap-1"
data-next-url="<?= esc($nextUrl, 'attr') ?>"
data-has-next="<?= $hasNext ? '1' : '0' ?>">
<button type="button" class="lazy-more px-4 py-1.5 text-xs border border-gray-300 rounded-full hover:bg-gray-50 text-gray-600<?= $hasNext ? '' : ' hidden' ?>">더보기</button>
<span class="lazy-status text-[11px] text-gray-400"></span>
</div>
<script>
(function () {
if (window.__lazyPagerInit) { window.__lazyPagerInit(); return; }
function lastTableBefore(el, root) {
var tables = root.querySelectorAll('table');
var found = null;
for (var i = 0; i < tables.length; i++) {
if (el.compareDocumentPosition(tables[i]) & Node.DOCUMENT_POSITION_PRECEDING) found = tables[i];
}
return found;
}
function bodyOf(table) { return table ? (table.tBodies[0] || table.querySelector('tbody')) : null; }
function initOne(sentinel) {
if (sentinel.getAttribute('data-lazy-init')) return;
sentinel.setAttribute('data-lazy-init', '1');
var tbody = bodyOf(lastTableBefore(sentinel, document));
if (!tbody) return;
var moreBtn = sentinel.querySelector('.lazy-more');
var statusEl = sentinel.querySelector('.lazy-status');
var loading = false;
var io = null;
var setStatus = function (t) { if (statusEl) statusEl.textContent = t || ''; };
async function loadNext() {
if (loading) return;
if (sentinel.getAttribute('data-has-next') !== '1') return;
var url = sentinel.getAttribute('data-next-url');
if (!url) return;
loading = true; setStatus('불러오는 중…');
try {
var res = await fetch(url, { credentials: 'same-origin', headers: { 'X-Requested-With': 'fetch' } });
var html = await res.text();
var doc = new DOMParser().parseFromString(html, 'text/html');
var fSentinel = doc.querySelector('.lazy-pager');
var fBody = bodyOf(fSentinel ? lastTableBefore(fSentinel, doc) : null);
var added = 0;
if (fBody) {
fBody.querySelectorAll('tr').forEach(function (tr) {
if (tr.children.length === 1 && tr.children[0].hasAttribute('colspan')) return; // 빈 목록 placeholder 제외
tbody.appendChild(document.importNode(tr, true));
added++;
});
}
if (fSentinel && fSentinel.getAttribute('data-has-next') === '1') {
sentinel.setAttribute('data-next-url', fSentinel.getAttribute('data-next-url'));
setStatus('');
} else {
sentinel.setAttribute('data-has-next', '0');
if (moreBtn) moreBtn.classList.add('hidden');
setStatus('모두 불러왔습니다');
if (io) io.disconnect();
}
} catch (e) {
setStatus('불러오기 실패 — [더보기]로 다시 시도해 주세요.');
}
loading = false;
}
if (moreBtn) moreBtn.addEventListener('click', loadNext);
// 사용자가 실제로 스크롤한 뒤에만 자동 로드(내부 스크롤 리스트에서 로드 즉시 전체 로딩 방지).
// 스크롤 전에는 [더보기] 버튼으로 불러온다.
if (!window.__lazyUserScrolled) {
var mark = function () { window.__lazyUserScrolled = true; };
window.addEventListener('scroll', mark, { passive: true, capture: true, once: true });
window.addEventListener('wheel', mark, { passive: true, once: true });
window.addEventListener('touchmove', mark, { passive: true, once: true });
}
if ('IntersectionObserver' in window) {
io = new IntersectionObserver(function (entries) {
entries.forEach(function (en) { if (en.isIntersecting && window.__lazyUserScrolled) loadNext(); });
}, { root: null, rootMargin: '0px 0px 320px 0px', threshold: 0 });
io.observe(sentinel);
}
}
window.__lazyPagerInit = function () {
document.querySelectorAll('.lazy-pager:not([data-lazy-init])').forEach(initOne);
};
window.__lazyPagerInit();
})();
</script>
<?php endif; ?>