Infinite Scroll

Load more records when the user reaches the end of the list. Backend uses take and skip (derived from page and perPage). Frontend observes a sentinel with IntersectionObserver.

Backend (route.php)

<?php

use Lib\Prisma\Classes\Prisma;
use Lib\Request;

$prisma  = Prisma::getInstance();
$perPage = (int) (Request::$params->perPage ?? 5);
$page    = max((int) (Request::$params->page ?? 1), 1);
$skip    = ($page - 1) * $perPage;

$logs = $prisma->versionLog->findMany([
    'orderBy' => ['createdAt' => 'desc'],
    'take'    => $perPage,
    'skip'    => $skip,
    'select'  => [
        'id'        => true,
        'version'   => true,
        'createdAt' => true,
        'title'     => true,
        'description'=> true,
    ],
]);

$nextPage = count($logs) === $perPage ? $page + 1 : null;

echo json_encode([
    'data'     => $logs,
    'nextPage' => $nextPage,
]);

Initial Frontend (index.php)

<?php

use Lib\Prisma\Classes\Prisma;

$prisma = Prisma::getInstance();

$logs = $prisma->versionLog->findMany([
    'orderBy' => ['createdAt' => 'desc'],
    'take'    => 5,
    'select'  => [
        'id'        => true,
        'version'   => true,
        'createdAt' => true,
        'title'     => true,
        'description'=> true,
    ],
]);

?>

<div class="w-full overflow-auto">
  <div class="w-full max-w-3xl mx-auto px-4 py-12 space-y-10">

    <ul id="log-list" class="space-y-8">
      <?php foreach ($logs as $log): 
        $date = new DateTime($log->createdAt);
      ?>
        <li class="space-y-2">
          <h2 class="text-xl font-semibold"><?= $log->version ?></h2>
          <p class="text-sm text-base-content/60">
            Released on 
            <time datetime="<?= $date->format('Y-m-d') ?>"><?= $date->format('F j, Y') ?></time>
          </p>
          <p class="text-sm"><?= $log->title ?> — <?= $log->description ?></p>
        </li>
      <?php endforeach; ?>
    </ul>

    <div id="scroll-sentinel" class="h-4 w-full"></div>

    <p id="end-message" class="text-center mt-6 text-sm text-base-content/60 hidden">
      No more data to load
    </p>

    <div id="loader" class="flex justify-center py-6 hidden">
      <span class="loading loading-spinner loading-md"></span>
    </div>

  </div>
</div>

<script>
let page      = 2;       // next page (1 already rendered)
let isLoading = false;
let hasMore   = true;

const sentinel = document.getElementById('scroll-sentinel');
const listEl   = document.getElementById('log-list');
const loader   = document.getElementById('loader');
const endMsg   = document.getElementById('end-message');

async function loadMore() {
  if (isLoading || !hasMore) return;
  isLoading = true;
  loader.classList.remove('hidden');

  try {
    const res  = await pphp.fetch(`/docs/change-log?page=${page}&perPage=5`);
    if (!res.ok) throw new Error('Request failed');
    const json = await res.json();

    const data = json.data ?? [];
    hasMore    = Boolean(json.nextPage);
    if (hasMore) page = json.nextPage;

    if (data.length) {
      const html = data.map(renderItem).join('');
      listEl.insertAdjacentHTML('beforeend', html);
    }

    if (!hasMore) {
      observer.disconnect();
      endMsg.classList.remove('hidden');
    }
  } catch (e) {
    console.error(e);
  } finally {
    loader.classList.add('hidden');
    isLoading = false;
  }
}

function renderItem(item) {
  const d      = new Date(item.createdAt);
  const dateISO= d.toISOString().split('T')[0];
  const dateUS = d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });

  return `
    <li class="space-y-2">
      <h2 class="text-xl font-semibold">${item.version}</h2>
      <p class="text-sm text-base-content/60">
        Released on <time datetime="${dateISO}">${dateUS}</time>
      </p>
      <p class="text-sm">${item.title} — ${item.description}</p>
    </li>
  `;
}

const observer = new IntersectionObserver(entries => {
  if (entries[0].isIntersecting) loadMore();
}, { rootMargin: '200px' });

observer.observe(sentinel);
</script>

Tips

  • DaisyUI loader: loading loading-spinner for a quick spinner.
  • isLoading: avoids duplicate requests if the sentinel fires repeatedly.
  • nextPage: when null, disconnect the observer and show a final message.
  • Error UI: add a toast/alert if the fetch fails.