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:
@@ -88,6 +88,7 @@ const initializeScanListener = function (this: any) {
|
||||
export { logout, restoreLibrarySelection };
|
||||
|
||||
Alpine.data("header", () => ({
|
||||
mobileMenuOpen: false,
|
||||
logout,
|
||||
initializeSearch,
|
||||
initializeTheme,
|
||||
|
||||
+53
-28
@@ -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) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user