Files
bookhoard/web/src/bookPicker.ts
T
John O'Keefe d7d1495753 fix(types): clear web/src type and lint debt with real types
tsc --noEmit and oxlint web/src are now both clean; vp check passes.

- htmx: one canonical Htmx interface in shims.d.ts (ajax/process/trigger
  per the htmx 2.x API). alpine.ts had a second, conflicting Window.htmx
  declaration (TS2717) — removed; bookPicker/collections lose their
  (window as any) escapes now that Window.htmx is properly typed.
- tsconfig lib ES2020 -> ES2021 + DOM.Iterable: replaceAll exists
  (escapeHtml) and for..of over querySelectorAll results is legal.
  Type-checking only — vite emits at esnext regardless.
- custom-section-builder: selectedBooks holds identity fields only;
  typed as Pick<BookInfo, ...> instead of forcing partial API shapes.
  The API-critical BookInfo contract is untouched.
- toast: real bug found by the type system — setup.ts passes 'warning'
  but getToastConfig had no entry, so the warning toast threw TypeError
  at runtime instead of displaying. Added the type + config. Uses
  bg-yellow-500 (verified present in scanned templates => generated by
  Tailwind); the one intentional behavior change in this commit.
- book-detail: instanceof narrowing replaces an as-cast; unused
  AnnotationSource import removed.
- password_validation: explicit HTMLElement | null for the checklist
  list (querySelector('ul') infers HTMLUListElement, conflicting with
  the container-as-list branch).
- stats: tooltip parsed value typed via annotation (bar=scalar,
  line={x,y}); dead getActiveRange() removed (was already tree-shaken
  out of every bundle).
- library-switcher: onSwitch honestly typed void | Promise<void>
  (series.ts passes a sync navigator).
- 20 unused catch bindings converted to optional catch binding.

Verification: fresh build is byte-reproducible; every chunk is
identical to the pre-fix build except main.js (instanceof narrowing,
regex fix, stats refactor), reader.js (destructure), and the shared
storage chunk (renamed — carries toast.ts). reader/main diffs audited
hunk-by-hunk against the intended edits.
2026-10-21 14:17:02 -04:00

135 lines
3.3 KiB
TypeScript

// Book Picker for Collections - multi-select book picker modal
// CRITICAL: Selection state must persist across HTMX grid updates
import { Alpine } from "./alpine";
import { showToast } from "./toast";
// Alpine.store for global book picker state
// Using store ensures state persists across HTMX DOM swaps
Alpine.store("bookPicker", {
isOpen: false,
selectedBooks: new Set<string>(),
open() {
this.isOpen = true;
this.selectedBooks.clear();
this.loadBooks();
},
close() {
this.isOpen = false;
this.selectedBooks.clear();
},
toggleBook(bookId: string) {
if (this.selectedBooks.has(bookId)) {
this.selectedBooks.delete(bookId);
} else {
this.selectedBooks.add(bookId);
}
// Trigger Alpine reactivity by creating new Set
this.selectedBooks = new Set(this.selectedBooks);
},
isSelected(bookId: string): boolean {
return this.selectedBooks.has(bookId);
},
get selectedCount(): number {
return this.selectedBooks.size;
},
async loadBooks() {
const grid = document.getElementById("book-picker-grid");
if (!grid) return;
// Trigger initial HTMX load
window.htmx.trigger(grid, "loadBooks");
},
clearFilters() {
const filterDiv = document.getElementById("book-picker-filters");
if (!filterDiv) return;
const inputs = filterDiv.querySelectorAll('input[type="text"]') as NodeListOf<HTMLInputElement>;
inputs.forEach((input) => {
input.value = "";
});
this.loadBooks();
},
async submit() {
const token = localStorage.getItem("token");
if (!token) {
showToast("Not authenticated", "error");
return;
}
if (this.selectedBooks.size === 0) {
showToast("No books selected", "error");
return;
}
// Get collection ID from current page
const pathParts = window.location.pathname.split("/");
const collectionId = pathParts[pathParts.length - 1];
try {
const response = await fetch(`/api/collections/${collectionId}/books`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
book_ids: Array.from(this.selectedBooks),
}),
});
if (response.ok) {
showToast(`Added ${this.selectedBooks.size} books to collection`, "success");
this.close();
location.reload();
} else {
showToast("Failed to add books", "error");
}
} catch (error) {
console.error("Failed to add books:", error);
showToast("Error adding books", "error");
}
},
});
// Alpine component for book picker
// Provides methods for template to access store
Alpine.data("bookPicker", () => ({
get isOpen() {
return window.Alpine?.$store.bookPicker.isOpen;
},
get selectedCount() {
return window.Alpine?.$store.bookPicker.selectedCount;
},
toggleBook(bookId: string) {
window.Alpine?.$store.bookPicker.toggleBook(bookId);
},
isSelected(bookId: string): boolean {
return window.Alpine?.$store.bookPicker.isSelected(bookId);
},
clearFilters() {
window.Alpine?.$store.bookPicker.clearFilters();
},
close() {
window.Alpine?.$store.bookPicker.close();
},
submit() {
window.Alpine?.$store.bookPicker.submit();
},
}));