{% 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>×</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 %}