{% extends "base.html" %}
{% block title %}Projects{% endblock %}
{% block page_class %}has-sticky-index{% endblock %}
{% block content %}
<a class="floating-new-project" href="/new" aria-label="New Project" title="New Project">+</a>
<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 page = document.querySelector(".has-sticky-index");
const sentinel = document.getElementById("scroll-sentinel");
const searchInput = document.getElementById("project-search");
const clearSearch = document.getElementById("clear-search");
const mobileSearchQuery = window.matchMedia("(max-width: 760px)");
let nextPage = {{ page + 1 if has_more else 0 }};
let loading = false;
let searchTimer = null;
let activeRequest = 0;
let blurTimer = null;
function updateKeyboardOffset() {
if (!mobileSearchQuery.matches || document.activeElement !== searchInput || !window.visualViewport) {
document.documentElement.style.setProperty("--keyboard-offset", "0px");
return;
}
const viewportBottom = window.visualViewport.height + window.visualViewport.offsetTop;
const keyboardOffset = Math.max(0, Math.round(window.innerHeight - viewportBottom));
document.documentElement.style.setProperty("--keyboard-offset", `${keyboardOffset}px`);
}
function keepSearchResultsVisible() {
if (!mobileSearchQuery.matches) return;
requestAnimationFrame(() => {
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
});
setTimeout(() => window.scrollTo(0, 0), 80);
}
function setSearchActive(active) {
clearTimeout(blurTimer);
if (!page) return;
page.classList.toggle("index-search-active", active && mobileSearchQuery.matches);
updateKeyboardOffset();
if (active) keepSearchResultsVisible();
}
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" : "";
if (!append) keepSearchResultsVisible();
}
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);
searchInput.addEventListener("focus", () => setSearchActive(true));
searchInput.addEventListener("blur", () => {
blurTimer = setTimeout(() => setSearchActive(false), 120);
});
clearSearch.addEventListener("click", () => {
searchInput.value = "";
searchInput.focus();
queueSearch();
});
updateClearButton();
function syncSearchActiveState() {
setSearchActive(document.activeElement === searchInput);
}
if (mobileSearchQuery.addEventListener) {
mobileSearchQuery.addEventListener("change", syncSearchActiveState);
} else if (mobileSearchQuery.addListener) {
mobileSearchQuery.addListener(syncSearchActiveState);
}
if (window.visualViewport) {
window.visualViewport.addEventListener("resize", () => {
updateKeyboardOffset();
if (document.activeElement === searchInput) keepSearchResultsVisible();
});
window.visualViewport.addEventListener("scroll", () => {
updateKeyboardOffset();
});
}
if ("IntersectionObserver" in window) {
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) loadNextPage();
});
observer.observe(sentinel);
}
</script>
{% endblock %}