Goal: Fetch books from the ASP.NET backend API and render them into the page, with search by ID, book reservation (loan), and a donate form that POSTs to the API.
1. Setup: API Base & State Variables
The API base URL is read from the <meta name="api-base"> tag, with a fallback to /api. State is kept in module-level variables inside an IIFE. AbortController lets us cancel in-flight ID searches if the user types faster than the response arrives.
const metaBase = document.querySelector('meta[name="api-base"]')
?.getAttribute('content')?.trim();
const API_BASE = (metaBase || '/api').replace(/\/$/, '');
let allBooksCache = []; // holds the full list from GET /books
let searchDebounceId = 0; // timeout ID for debouncing input
let idSearchAbort = null; // AbortController for cancelling in-flight fetches
2. Rendering: Template Cloning
Instead of building HTML strings, the <template> element is cloned and its inner elements are filled in. replaceChildren() clears the grid before adding new cards.
function createCardFromTemplate(book) {
const frag = bookTemplate.content.cloneNode(true);
const article = frag.querySelector('.book-card');
const onLoan = book.is_loaned === true;
article.dataset.bookId = String(book.id);
frag.querySelector('.card-badge').textContent = onLoan ? 'OnLoan' : 'Available';
frag.querySelector('.card-icon').textContent = ICONS[book.id % ICONS.length];
frag.querySelector('.card-title').textContent = book.title || '';
frag.querySelector('.card-meta').textContent =
[book.author, book.pub_year, book.library].filter(Boolean).join(' · ');
const btn = frag.querySelector('.reserve-btn');
btn.textContent = onLoan ? 'Loaned' : 'Reserve';
btn.disabled = onLoan;
return article;
}
function renderBookCards(books) {
booksContainer.replaceChildren();
for (const book of books)
booksContainer.appendChild(createCardFromTemplate(book));
}
3. Fetching Data (GET)
All books are fetched on page load and cached. A search by ID uses the /books/{id} endpoint. An AbortController cancels the previous request if a new one starts before it finishes.
async function fetchAllBooks() {
const res = await fetch(`${API_BASE}/books`);
if (!res.ok) throw new Error(`GET /books failed: ${res.status}`);
return res.json();
}
async function runIdSearch(idString) {
if (idSearchAbort) idSearchAbort.abort(); // cancel any previous request
idSearchAbort = new AbortController();
const res = await fetch(`${API_BASE}/books/${idString}`,
{ signal: idSearchAbort.signal }
);
if (res.status === 404) { showGridMessage('No book found'); return; }
if (!res.ok) throw new Error(`HTTP ${res.status}`);
renderBookCards([await res.json()]);
}
4. Debounced Search Input
Debouncing waits 280 ms after the user stops typing before running the search, so the API isn't hit on every keystroke.
function scheduleSearchDebounce() {
window.clearTimeout(searchDebounceId);
searchDebounceId = window.setTimeout(() => {
void syncDisplayWithSearch();
}, 280); // wait 280ms after last keystroke
}
searchInput.addEventListener('input', () => {
const v = searchInput.value.trim();
if (v === '') { renderBookCards(allBooksCache); return; }
scheduleSearchDebounce();
});
5. Donating a Book (POST)
The donate form is read with FormData, values are validated, then sent as JSON to POST /books. After success, the full book list is refreshed.
async function donateBook(payload) {
const res = await fetch(`${API_BASE}/books`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(payload), // { title, author, library, pub_year }
});
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.error || `HTTP ${res.status}`);
}
return res.json();
}
donateForm.addEventListener('submit', async (ev) => {
ev.preventDefault();
const fd = new FormData(donateForm);
const payload = {
title: String(fd.get('title') || '').trim(),
author: String(fd.get('author') || '').trim(),
library: String(fd.get('library') || '').trim(),
pub_year: parseInt(fd.get('pub_year'), 10),
};
await donateBook(payload);
donateForm.reset();
await refreshAllBooks();
await syncDisplayWithSearch();
});
6. Loaning a Book (PUT) via Event Delegation
Instead of attaching a click listener to every card button, one listener on the parent grid uses closest('.reserve-btn') to find the clicked button. This is event delegation — it works even for cards added dynamically after the listener was attached.
async function loanBook(id) {
const res = await fetch(`${API_BASE}/books/${id}/loan`, {
method: 'PUT',
headers: { Accept: 'application/json' },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text().then(t => t ? JSON.parse(t) : null);
}
// Event delegation on the container, not on each button
booksContainer.addEventListener('click', async (ev) => {
const btn = ev.target.closest('.reserve-btn');
if (!btn || btn.disabled) return;
const idStr = btn.closest('.book-card')?.dataset.bookId;
if (!idStr) return;
btn.disabled = true; // optimistically disable to prevent double-clicks
try {
await loanBook(idStr);
await refreshAllBooks();
await syncDisplayWithSearch();
} catch (e) {
window.alert(e.message);
btn.disabled = false; // re-enable on error
}
});