feat(header): add responsive mobile hamburger menu with slide-down panel

On viewports below 970px the header now collapses to a compact bar with
only the Bookhoard logo and a hamburger button. Clicking the hamburger
reveals a slide-down panel containing:

- Full-text search input (wired to the existing debounced search API)
- Navigation links (Library, All Books, Series, Collections, Progress, Devices)
- Collapsible theme switcher with all 7 themes and 4 bookshelf backgrounds
- User section: profile/admin/logout when logged in, inline login form when logged out

Changes:
- templates/header.templ: add hamburger button, mobile panel with all controls,
  hide desktop search/theme/user controls below nav breakpoint
- web/src/header.ts: add mobileMenuOpen state to Alpine header component
- web/src/search.ts: refactor initializeSearch to wire both desktop and mobile
  search inputs, track active input for results container placement
- tailwind.config.ts: add custom 'nav' screen breakpoint at 970px so the
  mobile menu activates before the search bar becomes unusable
- web/static/style.css: rebuilt with new nav breakpoint utility classes
This commit is contained in:
2026-05-24 21:20:36 -04:00
parent 13260d8c1f
commit d78a182f0d
6 changed files with 349 additions and 35 deletions
+1
View File
@@ -88,6 +88,7 @@ const initializeScanListener = function (this: any) {
export { logout, restoreLibrarySelection };
Alpine.data("header", () => ({
mobileMenuOpen: false,
logout,
initializeSearch,
initializeTheme,
+53 -28
View File
@@ -9,27 +9,37 @@ function initializeSearch(): void {
const searchInput = document.getElementById(
"header-search",
) as HTMLInputElement | null;
if (!searchInput) {
console.warn("Search input not found");
return;
}
const mobileSearchInput = document.getElementById(
"header-search-mobile",
) as HTMLInputElement | null;
searchInput.addEventListener("input", handleSearchInput);
searchInput.addEventListener("keydown", handleSearchKeydown);
searchInput.addEventListener("focus", () => {
if (searchInput.value.length >= SEARCH_MIN_CHARS) {
performSearch(searchInput.value);
}
});
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);
}
});
};
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 !== searchInputEl &&
e.target !== mobileSearchInputEl
) {
hideSearchResults();
}
@@ -97,6 +107,14 @@ function selectSearchResult(items: NodeListOf<Element>, index: number): void {
}
}
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) {
@@ -104,7 +122,10 @@ function performSearch(query: string): void {
return;
}
showSearchLoading();
const activeInput = getActiveSearchInput();
const activeId = activeInput?.id || "header-search";
showSearchLoading(activeId);
fetch(`/api/media-items/search?q=${encodeURIComponent(query)}`, {
headers: {
@@ -126,26 +147,28 @@ function performSearch(query: string): void {
) => {
hideSearchLoading();
const resultActiveId = getActiveSearchInput()?.id;
if (data && "error" in data && data.error === "no results found") {
showNoResults(query);
showNoResults(query, resultActiveId);
} else if (Array.isArray(data) && data.length > 0) {
showSearchResults(data, query);
showSearchResults(data, query, resultActiveId);
} else if (Array.isArray(data)) {
showNoResults(query);
showNoResults(query, resultActiveId);
} else {
showNoResults(query);
showNoResults(query, resultActiveId);
}
},
)
.catch((error) => {
hideSearchLoading();
console.error("Search error:", error);
showSearchError();
showSearchError(getActiveSearchInput()?.id);
});
}
function showSearchLoading(): void {
createSearchResultsContainer();
function showSearchLoading(activeId?: string): void {
createSearchResultsContainer(activeId);
const searchResults = document.getElementById("search-results");
if (!searchResults) return;
@@ -160,8 +183,8 @@ function showSearchLoading(): void {
function hideSearchLoading(): void {}
function showSearchResults(results: MediaItemSummary[], query: string): void {
createSearchResultsContainer();
function showSearchResults(results: MediaItemSummary[], query: string, activeId?: string): void {
createSearchResultsContainer(activeId);
const searchResults = document.getElementById("search-results");
if (!searchResults) return;
@@ -225,8 +248,8 @@ function showSearchResults(results: MediaItemSummary[], query: string): void {
searchResults.classList.remove("hidden");
}
function showNoResults(query: string): void {
createSearchResultsContainer();
function showNoResults(query: string, activeId?: string): void {
createSearchResultsContainer(activeId);
const searchResults = document.getElementById("search-results");
if (!searchResults) return;
@@ -240,8 +263,8 @@ function showNoResults(query: string): void {
searchResults.classList.remove("hidden");
}
function showSearchError(): void {
createSearchResultsContainer();
function showSearchError(activeId?: string): void {
createSearchResultsContainer(activeId);
const searchResults = document.getElementById("search-results");
if (!searchResults) return;
@@ -262,7 +285,7 @@ function hideSearchResults(): void {
}
}
function createSearchResultsContainer(): void {
function createSearchResultsContainer(activeId?: string): void {
let searchResults = document.getElementById("search-results");
if (!searchResults) {
searchResults = document.createElement("div");
@@ -272,7 +295,9 @@ function createSearchResultsContainer(): void {
searchResults.style.cssText =
"background-color: var(--bg-secondary); border-color: var(--border)";
const searchInput = document.getElementById("header-search");
const searchInput = document.getElementById(
activeId || "header-search",
);
if (searchInput) {
const searchContainer = searchInput.closest(".relative");
if (searchContainer) {
+1 -1
View File
File diff suppressed because one or more lines are too long