feat(search): viewer progress + completed badges; dropdown to templ
Search results now show where you are in a book: a completed check at the 0.999 threshold (same cut as the filter status arms), a partial progress bar along the cover while mid-read, nothing when you never opened it. Rendered in BookCard (any grid whose adapter loads viewer progress) and in the header-search dropdown rows. The progress rides a LEFT JOIN reading_progress on the SAME user_id param SearchMediaItemsUnified already binds for library visibility — one requester param, no new wire inputs, row-neutral join (the table is UNIQUE per media_item+user). SearchMediaItemsUnifiedRow and BookInfo gain the keys additively (viewer_percentage null when unknown, library_name empty on feeds that don't load it); contract_pins.md carries the changelog entry and the pin tests widen in lockstep. The header dropdown itself migrates off the innerHTML string renderer in search.ts (a rendering-ownership violation: a templ twin existed via the HX-Request branch). The input now hx-gets layout=dropdown and swaps the new templ SearchResults partial — titles/authors highlighted server-side (escape-first, safe by construction), rows with the same progress overlays — while a slim Alpine component owns only client-local state (min-length gating, open/close, keyboard selection, htmx lifecycle). web/src/rendering-allowlist.txt drops the search.ts baseline to zero accordingly. Integration test pins the per-user join (fresh user sees 1.0/0.42/null on the fixture; the shared admin sees null everywhere — read-only) and the dropdown partial's overlays/count/row count.
This commit is contained in:
1 parent
d1f9ad3b25
commit
61e5f47ec0
18 files changed
+1140
-616
No files matched your search
+91
-298
@@ -1,315 +1,108 @@
|
||||
import { Alpine } from "./alpine";
|
||||
|
||||
let searchInputTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
// Rendering ownership (AGENTS.md): the dropdown's CONTENT is templ —
|
||||
// the header input's hx-get swaps the SearchResults partial (titles,
|
||||
// authors, library names, progress/completed overlays all
|
||||
// server-rendered) into #search-results. This module owns only
|
||||
// client-local state: min-length gating, open/close, and keyboard
|
||||
// selection. It replaces the old renderer that built the dropdown from
|
||||
// innerHTML string templates — a violation of the rendering-ownership
|
||||
// doctrine this migration removes (2026-10-27 search-progress batch).
|
||||
|
||||
const SEARCH_MIN_CHARS = 2;
|
||||
|
||||
function initializeSearch(): void {
|
||||
const searchInput = document.getElementById("header-search") as HTMLInputElement | null;
|
||||
const mobileSearchInput = document.getElementById(
|
||||
"header-search-mobile",
|
||||
) as HTMLInputElement | null;
|
||||
interface HtmxAfterRequestEvent extends CustomEvent {
|
||||
detail: { successful: boolean };
|
||||
}
|
||||
|
||||
const wireInput = (input: HTMLInputElement) => {
|
||||
input.addEventListener("input", handleSearchInput);
|
||||
input.addEventListener("keydown", handleSearchKeydown);
|
||||
input.addEventListener("focus", () => {
|
||||
if (input.value.length >= SEARCH_MIN_CHARS) {
|
||||
performSearch(input.value);
|
||||
}
|
||||
Alpine.data("headerSearch", () => ({
|
||||
open: false,
|
||||
loading: false,
|
||||
selected: -1,
|
||||
lastQuery: "",
|
||||
|
||||
init() {
|
||||
// htmx dispatches request lifecycle events on the REQUESTING
|
||||
// element (the input), not the swap target.
|
||||
const input = this.$refs.input as HTMLInputElement | undefined;
|
||||
if (!input) return;
|
||||
input.addEventListener("htmx:beforeRequest", () => {
|
||||
this.loading = true;
|
||||
});
|
||||
};
|
||||
|
||||
if (searchInput) wireInput(searchInput);
|
||||
if (mobileSearchInput) wireInput(mobileSearchInput);
|
||||
|
||||
if (!searchInput && !mobileSearchInput) {
|
||||
console.warn("Search input not found");
|
||||
}
|
||||
|
||||
document.addEventListener("click", (e: MouseEvent) => {
|
||||
const searchResults = document.getElementById("search-results");
|
||||
const searchInputEl = document.getElementById("header-search");
|
||||
const mobileSearchInputEl = document.getElementById("header-search-mobile");
|
||||
|
||||
if (
|
||||
searchResults &&
|
||||
!searchResults.contains(e.target as Node) &&
|
||||
e.target !== searchInputEl &&
|
||||
e.target !== mobileSearchInputEl
|
||||
) {
|
||||
hideSearchResults();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleSearchInput(e: Event): void {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const query = target.value.trim();
|
||||
|
||||
if (searchInputTimeout) {
|
||||
clearTimeout(searchInputTimeout);
|
||||
}
|
||||
|
||||
if (query.length < SEARCH_MIN_CHARS) {
|
||||
hideSearchResults();
|
||||
return;
|
||||
}
|
||||
|
||||
searchInputTimeout = setTimeout(() => {
|
||||
performSearch(query);
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function handleSearchKeydown(e: KeyboardEvent): void {
|
||||
const searchResults = document.getElementById("search-results");
|
||||
if (!searchResults || searchResults.classList.contains("hidden")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const items = searchResults.querySelectorAll(".search-result-item");
|
||||
const currentIndex = parseInt(searchResults.dataset.selectedIndex || "-1");
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
const nextIndex = Math.min(currentIndex + 1, items.length - 1);
|
||||
selectSearchResult(items, nextIndex);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
const prevIndex = Math.max(currentIndex - 1, -1);
|
||||
selectSearchResult(items, prevIndex);
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (currentIndex >= 0 && items[currentIndex]) {
|
||||
const link = items[currentIndex].querySelector("a");
|
||||
if (link) link.click();
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
hideSearchResults();
|
||||
}
|
||||
}
|
||||
|
||||
function selectSearchResult(items: NodeListOf<Element>, index: number): void {
|
||||
items.forEach((item, i) => {
|
||||
if (i === index) {
|
||||
item.classList.add("bg-opacity-80");
|
||||
} else {
|
||||
item.classList.remove("bg-opacity-80");
|
||||
}
|
||||
});
|
||||
|
||||
const searchResults = document.getElementById("search-results");
|
||||
if (searchResults) {
|
||||
searchResults.dataset.selectedIndex = index.toString();
|
||||
}
|
||||
}
|
||||
|
||||
function getActiveSearchInput(): HTMLInputElement | null {
|
||||
return document.activeElement?.id === "header-search-mobile"
|
||||
? (document.getElementById("header-search-mobile") as HTMLInputElement)
|
||||
: (document.getElementById("header-search") as HTMLInputElement);
|
||||
}
|
||||
|
||||
function performSearch(query: string): void {
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) {
|
||||
console.warn("No authentication token found");
|
||||
return;
|
||||
}
|
||||
|
||||
const activeInput = getActiveSearchInput();
|
||||
const activeId = activeInput?.id || "header-search";
|
||||
|
||||
showSearchLoading(activeId);
|
||||
|
||||
fetch(`/api/media-items/search?q=${encodeURIComponent(query)}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.status === 404) {
|
||||
return { error: "no results found", results: [] };
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((data: { error?: string; results?: MediaItemSummary[] } | MediaItemSummary[]) => {
|
||||
hideSearchLoading();
|
||||
|
||||
const resultActiveId = getActiveSearchInput()?.id;
|
||||
|
||||
if (data && "error" in data && data.error === "no results found") {
|
||||
showNoResults(query, resultActiveId);
|
||||
} else if (Array.isArray(data) && data.length > 0) {
|
||||
showSearchResults(data, query, resultActiveId);
|
||||
} else if (Array.isArray(data)) {
|
||||
showNoResults(query, resultActiveId);
|
||||
input.addEventListener("htmx:afterRequest", (evt: Event) => {
|
||||
this.loading = false;
|
||||
const detail = (evt as HtmxAfterRequestEvent).detail;
|
||||
// htmx swaps only 2xx bodies: a failed request closes instead of
|
||||
// swapping an error page into the dropdown.
|
||||
if (detail && detail.successful) {
|
||||
this.open = true;
|
||||
this.selected = -1;
|
||||
this.lastQuery = this.query();
|
||||
} else {
|
||||
showNoResults(query, resultActiveId);
|
||||
this.open = false;
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
hideSearchLoading();
|
||||
console.error("Search error:", error);
|
||||
showSearchError(getActiveSearchInput()?.id);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
function showSearchLoading(activeId?: string): void {
|
||||
createSearchResultsContainer(activeId);
|
||||
const searchResults = document.getElementById("search-results");
|
||||
if (!searchResults) return;
|
||||
query(): string {
|
||||
const input = this.$refs.input as HTMLInputElement | undefined;
|
||||
return input ? input.value.trim() : "";
|
||||
},
|
||||
|
||||
searchResults.innerHTML = `
|
||||
<div class="p-4 text-center" style="color: var(--text-secondary)">
|
||||
<div class="inline-block animate-spin rounded-full h-6 w-6 border-b-2" style="border-color: var(--accent)"></div>
|
||||
<p class="mt-2 text-sm">Searching...</p>
|
||||
</div>
|
||||
`;
|
||||
searchResults.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function hideSearchLoading(): void {}
|
||||
|
||||
function showSearchResults(results: MediaItemSummary[], query: string, activeId?: string): void {
|
||||
createSearchResultsContainer(activeId);
|
||||
const searchResults = document.getElementById("search-results");
|
||||
if (!searchResults) return;
|
||||
|
||||
searchResults.dataset.selectedIndex = "-1";
|
||||
|
||||
let html = `
|
||||
<div class="p-3 border-b" style="border-color: var(--border)">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">
|
||||
${results.length} result${results.length !== 1 ? "s" : ""} for "${searchEscapeHtml(query)}"
|
||||
</p>
|
||||
</div>
|
||||
<div class="max-h-96 overflow-y-auto">
|
||||
`;
|
||||
|
||||
results.forEach((item, index) => {
|
||||
const titleHtml = highlightMatch(item.title, query);
|
||||
const authorHtml = item.author ? highlightMatch(item.author, query) : "";
|
||||
const coverUrl = item.cover_image_path || "/static/placeholder-book.svg";
|
||||
|
||||
html += `
|
||||
<div class="search-result-item p-3 border-b hover:bg-opacity-50 transition-colors cursor-pointer"
|
||||
style="border-color: var(--border); background-color: var(--bg-secondary)"
|
||||
data-index="${index}">
|
||||
<a href="/media/${item.id}" class="block">
|
||||
<div class="flex items-start space-x-3">
|
||||
<div class="w-10 h-14 flex-shrink-0 rounded overflow-hidden bg-gradient-to-br from-gray-700 to-gray-900">
|
||||
<img src="${coverUrl}"
|
||||
alt="${searchEscapeHtml(item.title)}"
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
onerror="this.src='/static/placeholder-book.svg'">
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-sm font-medium truncate" style="color: var(--text-primary)">
|
||||
${titleHtml}
|
||||
</h4>
|
||||
${authorHtml ? `<p class="text-xs truncate" style="color: var(--text-secondary)">${authorHtml}</p>` : ""}
|
||||
<p class="text-xs mt-1" style="color: var(--text-secondary)">
|
||||
${searchEscapeHtml(item.library_name)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
html += `
|
||||
</div>
|
||||
<div class="p-2 border-t text-center" style="border-color: var(--border)">
|
||||
<p class="text-xs" style="color: var(--text-secondary)">
|
||||
Press <kbd class="px-1 py-0.5 rounded" style="background-color: var(--bg-primary)">↑↓</kbd> to navigate,
|
||||
<kbd class="px-1 py-0.5 rounded" style="background-color: var(--bg-primary)">Enter</kbd> to select
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
searchResults.innerHTML = html;
|
||||
searchResults.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function showNoResults(query: string, activeId?: string): void {
|
||||
createSearchResultsContainer(activeId);
|
||||
const searchResults = document.getElementById("search-results");
|
||||
if (!searchResults) return;
|
||||
|
||||
searchResults.innerHTML = `
|
||||
<div class="p-4 text-center">
|
||||
<div class="text-4xl mb-2">🔍</div>
|
||||
<p class="text-sm" style="color: var(--text-primary)">No results found for "${searchEscapeHtml(query)}"</p>
|
||||
<p class="text-xs mt-1" style="color: var(--text-secondary)">Try different keywords</p>
|
||||
</div>
|
||||
`;
|
||||
searchResults.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function showSearchError(activeId?: string): void {
|
||||
createSearchResultsContainer(activeId);
|
||||
const searchResults = document.getElementById("search-results");
|
||||
if (!searchResults) return;
|
||||
|
||||
searchResults.innerHTML = `
|
||||
<div class="p-4 text-center">
|
||||
<div class="text-4xl mb-2">⚠️</div>
|
||||
<p class="text-sm" style="color: var(--text-primary)">Search error</p>
|
||||
<p class="text-xs mt-1" style="color: var(--text-secondary)">Please try again</p>
|
||||
</div>
|
||||
`;
|
||||
searchResults.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function hideSearchResults(): void {
|
||||
const searchResults = document.getElementById("search-results");
|
||||
if (searchResults) {
|
||||
searchResults.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function createSearchResultsContainer(activeId?: string): void {
|
||||
let searchResults = document.getElementById("search-results");
|
||||
if (!searchResults) {
|
||||
searchResults = document.createElement("div");
|
||||
searchResults.id = "search-results";
|
||||
searchResults.className =
|
||||
"hidden absolute z-50 w-full max-w-2xl mt-2 rounded-lg shadow-lg border";
|
||||
searchResults.style.cssText =
|
||||
"background-color: var(--bg-secondary); border-color: var(--border)";
|
||||
|
||||
const searchInput = document.getElementById(activeId || "header-search");
|
||||
if (searchInput) {
|
||||
const searchContainer = searchInput.closest(".relative");
|
||||
if (searchContainer) {
|
||||
searchContainer.appendChild(searchResults);
|
||||
}
|
||||
onInput() {
|
||||
const q = this.query();
|
||||
if (q.length < SEARCH_MIN_CHARS) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// htmx owns the request (its `search` trigger carries the 300ms
|
||||
// delay); this side only gates the minimum length. No trigger
|
||||
// filter expressions — CSP has no 'unsafe-eval'.
|
||||
window.htmx.trigger(this.$refs.input as HTMLElement, "search");
|
||||
},
|
||||
|
||||
function highlightMatch(text: string, query: string): string {
|
||||
if (!text) return "";
|
||||
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const regex = new RegExp(`(${escapedQuery})`, "gi");
|
||||
return searchEscapeHtml(text).replace(
|
||||
regex,
|
||||
'<mark style="background-color: var(--accent); color: var(--bg-primary); padding: 0 2px; border-radius: 2px;">$1</mark>',
|
||||
);
|
||||
}
|
||||
onFocus() {
|
||||
if (this.query().length < SEARCH_MIN_CHARS) return;
|
||||
if (this.lastQuery === this.query() && this.hasResults()) {
|
||||
this.open = true;
|
||||
} else {
|
||||
window.htmx.trigger(this.$refs.input as HTMLElement, "search");
|
||||
}
|
||||
},
|
||||
|
||||
function searchEscapeHtml(text: string): string {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
hasResults(): boolean {
|
||||
const results = document.getElementById("search-results");
|
||||
return !!results && results.childElementCount > 0;
|
||||
},
|
||||
|
||||
export { initializeSearch };
|
||||
resultItems(): HTMLElement[] {
|
||||
const results = document.getElementById("search-results");
|
||||
if (!results) return [];
|
||||
return Array.from(results.querySelectorAll<HTMLElement>(".search-result-item"));
|
||||
},
|
||||
|
||||
Alpine.data("search", () => ({
|
||||
initializeSearch,
|
||||
moveSelection(delta: number) {
|
||||
if (!this.open) return;
|
||||
const items = this.resultItems();
|
||||
if (items.length === 0) return;
|
||||
let next = this.selected + delta;
|
||||
if (next < -1) next = -1;
|
||||
if (next >= items.length) next = items.length - 1;
|
||||
this.selected = next;
|
||||
items.forEach((el, i) => {
|
||||
el.style.backgroundColor = i === next ? "var(--surface-hover)" : "";
|
||||
});
|
||||
if (next >= 0) items[next].scrollIntoView({ block: "nearest" });
|
||||
},
|
||||
|
||||
openSelected() {
|
||||
if (!this.open || this.selected < 0) return;
|
||||
const link = this.resultItems()[this.selected]?.querySelector("a");
|
||||
if (link) (link as HTMLAnchorElement).click();
|
||||
},
|
||||
|
||||
close() {
|
||||
this.open = false;
|
||||
this.selected = -1;
|
||||
},
|
||||
}));
|
||||
Reference in new issue
Block a user