patx/projectpay

{% extends "base.html" %}
{% block title %}Projects{% endblock %}
{% block page_class %}has-sticky-index{% endblock %}
{% block content %}
  <div class="sticky-index-top">
    <div class="sticky-index-actions">
      <a class="button" href="/new">New Project</a>
      <a class="button logout-button" href="/logout">Logout</a>
    </div>
  </div>

  <div class="sticky-index-search">
    <div class="sticky-index-search-inner">
      <div class="searchbar">
        <div class="search-field">
          <input id="project-search" name="q" value="{{ q }}" placeholder="Search project number, status, or customer" autocomplete="off">
          <button class="search-clear" id="clear-search" type="button" aria-label="Clear search" hidden>&times;</button>
        </div>
      </div>
    </div>
  </div>

  <div class="project-list" id="project-list" data-query="{{ q }}">
    {% include "_project_rows.html" %}
  </div>
  <div id="scroll-sentinel" class="footer-note">
    {% if has_more %}Loading more projects as you scroll{% endif %}
  </div>

  <script>
    const list = document.getElementById("project-list");
    const sentinel = document.getElementById("scroll-sentinel");
    const searchInput = document.getElementById("project-search");
    const clearSearch = document.getElementById("clear-search");
    let nextPage = {{ page + 1 if has_more else 0 }};
    let loading = false;
    let searchTimer = null;
    let activeRequest = 0;

    function applyRows(html, append) {
      const wrapper = document.createElement("div");
      wrapper.innerHTML = html;
      const marker = wrapper.querySelector("[data-next-page]");
      if (!append) {
        list.innerHTML = "";
      }
      wrapper.querySelectorAll(".project-card, .empty").forEach((row) => {
        list.appendChild(row);
      });
      nextPage = marker && marker.dataset.hasMore === "1" ? Number(marker.dataset.nextPage) : 0;
      sentinel.textContent = nextPage ? "Loading more projects as you scroll" : "";
    }

    async function fetchProjects(page, append) {
      loading = true;
      const q = encodeURIComponent(list.dataset.query || "");
      const requestId = ++activeRequest;
      const response = await fetch(`/projects_page?page=${page}&q=${q}`);
      if (!response.ok) {
        loading = false;
        return;
      }
      const html = await response.text();
      if (requestId === activeRequest) applyRows(html, append);
      loading = false;
    }

    function loadNextPage() {
      if (!nextPage || loading) return;
      fetchProjects(nextPage, true);
    }

    function updateClearButton() {
      clearSearch.hidden = searchInput.value.length === 0;
    }

    function queueSearch() {
      clearTimeout(searchTimer);
      searchTimer = setTimeout(() => {
        list.dataset.query = searchInput.value;
        nextPage = 0;
        fetchProjects(1, false);
      }, 200);
      updateClearButton();
    }

    searchInput.addEventListener("input", queueSearch);
    clearSearch.addEventListener("click", () => {
      searchInput.value = "";
      searchInput.focus();
      queueSearch();
    });
    updateClearButton();

    if ("IntersectionObserver" in window) {
      const observer = new IntersectionObserver((entries) => {
        if (entries.some((entry) => entry.isIntersecting)) loadNextPage();
      });
      observer.observe(sentinel);
    }
  </script>
{% endblock %}