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.
105 lines
3.4 KiB
TypeScript
105 lines
3.4 KiB
TypeScript
import { Alpine } from "./alpine";
|
|
import { ALL_LIBRARIES, getSelectedLibrary, setSelectedLibrary } from "./storage";
|
|
import { showToast } from "./toast";
|
|
|
|
export function getCurrentLibraryId(): string {
|
|
return getSelectedLibrary();
|
|
}
|
|
|
|
export async function switchWithTransition(
|
|
containerId: string,
|
|
fetchFn: () => Promise<void>,
|
|
): Promise<void> {
|
|
const container = document.getElementById(containerId);
|
|
const loading = document.getElementById("loading-spinner");
|
|
|
|
if (!container || !loading) return;
|
|
|
|
// View Transitions (native, where supported): the browser snapshots the
|
|
// old shelves, fetchFn swaps the DOM, and the two snapshots crossfade —
|
|
// no blank phase, no manual opacity choreography. The dim + spinner is
|
|
// shown before the snapshot and hidden inside the update, so the dim
|
|
// blends away as part of the crossfade. Browsers without the API fall
|
|
// through to the dim + fade sequence below.
|
|
const doc = document as Document & {
|
|
startViewTransition?: (updateCallback: () => void | Promise<void>) => {
|
|
finished: Promise<void>;
|
|
};
|
|
};
|
|
if (doc.startViewTransition) {
|
|
try {
|
|
loading.classList.remove("hidden");
|
|
const transition = doc.startViewTransition(async () => {
|
|
await fetchFn();
|
|
loading.classList.add("hidden");
|
|
});
|
|
await transition.finished;
|
|
} catch (error) {
|
|
loading.classList.add("hidden");
|
|
showToast("Failed to load library", "error");
|
|
console.error("Switch library error:", error);
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
container.classList.add("opacity-0", "transition-opacity", "duration-150");
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
|
|
loading.classList.remove("hidden");
|
|
|
|
await fetchFn();
|
|
|
|
// Lift the dim BEFORE the fade-in starts, so the new content's fade
|
|
// plays fully visible instead of being hard-cut by the overlay
|
|
// disappearing mid-transition.
|
|
loading.classList.add("hidden");
|
|
|
|
container.classList.remove("duration-150");
|
|
container.classList.add("duration-300");
|
|
void container.offsetHeight;
|
|
container.classList.remove("opacity-0");
|
|
|
|
setTimeout(() => {
|
|
container.classList.remove("transition-opacity", "duration-300");
|
|
}, 300);
|
|
} catch (error) {
|
|
container.classList.remove("opacity-0", "transition-opacity", "duration-150", "duration-300");
|
|
showToast("Failed to load library", "error");
|
|
console.error("Switch library error:", error);
|
|
} finally {
|
|
loading.classList.add("hidden");
|
|
}
|
|
}
|
|
|
|
export interface LibrarySwitcherOptions {
|
|
// Async switchers (SSR navigation awaits nothing, API-backed switchers
|
|
// await their fetch) — hence void | Promise<void>.
|
|
onSwitch: (libraryId: string) => void | Promise<void>;
|
|
}
|
|
|
|
export function initLibrarySwitcher(options: LibrarySwitcherOptions): void {
|
|
const librarySelect = document.getElementById("library-select") as HTMLSelectElement;
|
|
if (!librarySelect) return;
|
|
|
|
const stored = localStorage.getItem("selectedLibrary");
|
|
if (stored === ALL_LIBRARIES) {
|
|
librarySelect.value = "";
|
|
} else if (stored) {
|
|
const option = librarySelect.querySelector(`option[value="${stored}"]`);
|
|
if (option) {
|
|
librarySelect.value = stored;
|
|
}
|
|
}
|
|
|
|
librarySelect.addEventListener("change", async (e) => {
|
|
const target = e.target as HTMLSelectElement;
|
|
const libraryId = target.value;
|
|
|
|
setSelectedLibrary(libraryId);
|
|
await options.onSwitch(libraryId);
|
|
});
|
|
}
|
|
|
|
Alpine.data("librarySwitcher", () => ({}));
|