feat: 리스트 페이징을 lazy loading(무한 스크롤)으로 표준화
공용 CI4 pager 템플릿(components/pager.php)을 페이지 번호 대신 무한 스크롤 방식으로 교체. 화면 하단 도달 시(IntersectionObserver) 다음 페이지 HTML을 받아 리스트 표에 이어붙이고, '더보기' 버튼을 폴백으로 둔다. Config\Pager의 default 템플릿이라 전체 리스트 화면에 일괄 적용된다. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,38 +1,109 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom Tailwind CSS Pagination View for CodeIgniter 4
|
* 공용 리스트 페이저 → Lazy Loading(무한 스크롤) 표준 컴포넌트.
|
||||||
|
*
|
||||||
|
* CI4 Pager 템플릿(Config\Pager: default_full/default_simple)으로 등록돼 있어,
|
||||||
|
* 모든 리스트 화면의 `<?= $pager->links() ?>` 호출이 이 컴포넌트를 렌더한다.
|
||||||
|
* 페이지 번호 대신 센티넬 + '더보기' 버튼을 두고, 화면 하단 도달 시(IntersectionObserver)
|
||||||
|
* 다음 페이지 HTML을 받아 바로 위 리스트 표(tbody)에 행을 이어붙인다.
|
||||||
*
|
*
|
||||||
* @var \CodeIgniter\Pager\PagerRenderer $pager
|
* @var \CodeIgniter\Pager\PagerRenderer $pager
|
||||||
*/
|
*/
|
||||||
|
|
||||||
$pager->setSurroundCount(2);
|
$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; }
|
||||||
|
|
||||||
<?php if ($pager->hasPreviousPage() || $pager->hasNextPage()): ?>
|
function lastTableBefore(el, root) {
|
||||||
<nav aria-label="Page navigation" class="flex items-center justify-center gap-1 mt-3 mb-2 no-print">
|
var tables = root.querySelectorAll('table');
|
||||||
<?php if ($pager->hasPreviousPage()): ?>
|
var found = null;
|
||||||
<a href="<?= $pager->getFirst() ?>" class="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-100 text-gray-600" title="처음">«</a>
|
for (var i = 0; i < tables.length; i++) {
|
||||||
<a href="<?= $pager->getPreviousPage() ?>" class="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-100 text-gray-600" title="이전">‹</a>
|
if (el.compareDocumentPosition(tables[i]) & Node.DOCUMENT_POSITION_PRECEDING) found = tables[i];
|
||||||
<?php else: ?>
|
}
|
||||||
<span class="px-2 py-1 text-xs border border-gray-200 rounded text-gray-300">«</span>
|
return found;
|
||||||
<span class="px-2 py-1 text-xs border border-gray-200 rounded text-gray-300">‹</span>
|
}
|
||||||
<?php endif; ?>
|
function bodyOf(table) { return table ? (table.tBodies[0] || table.querySelector('tbody')) : null; }
|
||||||
|
|
||||||
<?php foreach ($pager->links() as $link): ?>
|
function initOne(sentinel) {
|
||||||
<?php if ($link['active']): ?>
|
if (sentinel.getAttribute('data-lazy-init')) return;
|
||||||
<span class="px-3 py-1 text-xs border border-btn-search rounded bg-btn-search text-white font-bold"><?= $link['title'] ?></span>
|
sentinel.setAttribute('data-lazy-init', '1');
|
||||||
<?php else: ?>
|
var tbody = bodyOf(lastTableBefore(sentinel, document));
|
||||||
<a href="<?= $link['uri'] ?>" class="px-3 py-1 text-xs border border-gray-300 rounded hover:bg-gray-100 text-gray-700"><?= $link['title'] ?></a>
|
if (!tbody) return;
|
||||||
<?php endif; ?>
|
var moreBtn = sentinel.querySelector('.lazy-more');
|
||||||
<?php endforeach; ?>
|
var statusEl = sentinel.querySelector('.lazy-status');
|
||||||
|
var loading = false;
|
||||||
|
var io = null;
|
||||||
|
var setStatus = function (t) { if (statusEl) statusEl.textContent = t || ''; };
|
||||||
|
|
||||||
<?php if ($pager->hasNextPage()): ?>
|
async function loadNext() {
|
||||||
<a href="<?= $pager->getNextPage() ?>" class="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-100 text-gray-600" title="다음">›</a>
|
if (loading) return;
|
||||||
<a href="<?= $pager->getLast() ?>" class="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-100 text-gray-600" title="마지막">»</a>
|
if (sentinel.getAttribute('data-has-next') !== '1') return;
|
||||||
<?php else: ?>
|
var url = sentinel.getAttribute('data-next-url');
|
||||||
<span class="px-2 py-1 text-xs border border-gray-200 rounded text-gray-300">›</span>
|
if (!url) return;
|
||||||
<span class="px-2 py-1 text-xs border border-gray-200 rounded text-gray-300">»</span>
|
loading = true; setStatus('불러오는 중…');
|
||||||
<?php endif; ?>
|
try {
|
||||||
</nav>
|
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; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
Reference in New Issue
Block a user