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
+22
View File
@@ -21,6 +21,28 @@ const config: Config = {
],
theme: {
extend: {
screens: {
nav: "970px",
},
fontFamily: {
sans: [
"ui-sans-serif",
"system-ui",
"sans-serif",
'"Noto Sans SC"',
'"Noto Sans TC"',
'"Noto Sans JP"',
'"Noto Sans KR"',
'"PingFang SC"',
'"Microsoft YaHei"',
'"Hiragino Sans"',
'"Apple SD Gothic Neo"',
"Apple Color Emoji",
"Segoe UI Emoji",
"Segoe UI Symbol",
"Noto Color Emoji",
],
},
colors: {
primary: {
DEFAULT: "#7aa2f7",
+231 -3
View File
@@ -23,7 +23,7 @@ templ Header(user User, currentPath string) {
</svg>
<span x-show="scanning && scanProgress > 0" x-transition class="text-xs font-normal" style="color: var(--text-secondary);" x-text="scanProgress + '%'"></span>
</a>
<div class="hidden md:flex items-center space-x-4">
<div class="hidden nav:flex items-center space-x-4">
<a href="/dashboard" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Library
</a>
@@ -49,7 +49,7 @@ templ Header(user User, currentPath string) {
</div>
</div>
<!-- Center: Search Box -->
<div class="flex-1 max-w-2xl mx-8">
<div class="flex-1 max-w-2xl mx-8 hidden nav:block">
<div class="relative">
<input
type="text"
@@ -65,7 +65,7 @@ templ Header(user User, currentPath string) {
</div>
</div>
<!-- Right: Theme Switcher & User Menu -->
<div x-data="{ themeDropdownOpen: false, userMenuOpen: false }" class="flex items-center space-x-4">
<div x-data="{ themeDropdownOpen: false, userMenuOpen: false }" class="hidden nav:flex items-center space-x-4">
<!-- Theme Switcher -->
<div class="relative">
<button
@@ -296,6 +296,234 @@ templ Header(user User, currentPath string) {
}
</div>
</div>
<button
@click="mobileMenuOpen = !mobileMenuOpen"
type="button"
class="nav:hidden p-2 rounded-lg hover:opacity-80 transition-opacity"
style="background-color: var(--bg-primary); color: var(--text-primary);"
aria-label="Toggle menu"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
</div>
<div
x-show="mobileMenuOpen"
@click.outside="mobileMenuOpen = false"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-2"
class="nav:hidden border-t"
style="border-color: var(--border); background-color: var(--bg-secondary); display: none;"
x-data="{ mobileThemeOpen: false }"
>
<div class="px-4 py-3 space-y-1">
<div class="relative mb-3">
<input
type="text"
id="header-search-mobile"
placeholder="Search your library..."
class="w-full px-4 py-2 pl-10 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
autocomplete="off"
/>
<svg class="absolute left-3 top-2.5 h-5 w-5" style="color: var(--text-secondary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</div>
<a href="/dashboard" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Library
</a>
<a href="/bookshelf" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
All Books
</a>
<a href="/series" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Series
</a>
<a href="/collections" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Collections
</a>
<a href="/progress" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Progress
</a>
<a href="/devices" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Devices
</a>
<div class="border-t my-2" style="border-color: var(--border);"></div>
<button
@click="mobileThemeOpen = !mobileThemeOpen"
type="button"
class="flex items-center space-x-2 w-full px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-secondary);"
>
<svg class="h-5 w-5" style="color: var(--text-secondary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"></path>
</svg>
<span>Theme</span>
<svg class="h-4 w-4 ml-auto transform transition-transform" :class="{ 'rotate-180': mobileThemeOpen }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<div x-show="mobileThemeOpen" x-transition class="pl-8 pr-2 py-1 space-y-1">
<button
@click="changeTheme('tokyo-night'); mobileThemeOpen = false"
type="button"
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #7aa2f7;"></span>
Tokyo Night
</button>
<button
@click="changeTheme('dracula'); mobileThemeOpen = false"
type="button"
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #bd93f9;"></span>
Dracula
</button>
<button
@click="changeTheme('nord'); mobileThemeOpen = false"
type="button"
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #88c0d0;"></span>
Nord
</button>
<button
@click="changeTheme('solarized-dark'); mobileThemeOpen = false"
type="button"
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #2aa198;"></span>
Solarized Dark
</button>
<button
@click="changeTheme('monokai'); mobileThemeOpen = false"
type="button"
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #a6e22e;"></span>
Monokai
</button>
<button
@click="changeTheme('one-dark-pro'); mobileThemeOpen = false"
type="button"
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #61dafb;"></span>
One Dark Pro
</button>
<button
@click="changeTheme('material-dark'); mobileThemeOpen = false"
type="button"
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #80cbc4;"></span>
Material Dark
</button>
<div class="border-t pt-2 mt-2" style="border-color: var(--border);">
<p class="text-xs mb-2 px-3" style="color: var(--text-secondary)">Bookshelf Background</p>
<button
@click="changeWoodPaneling('none'); mobileThemeOpen = false"
type="button"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
data-wood="none"
>
None
</button>
<button
@click="changeWoodPaneling('wood-light'); mobileThemeOpen = false"
type="button"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
data-wood="wood-light"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background: url('/static/textures/wood-light.png'); background-size: cover;"></span>
Wood Light
</button>
<button
@click="changeWoodPaneling('wood-dark'); mobileThemeOpen = false"
type="button"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
data-wood="wood-dark"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background: url('/static/textures/wood-dark.png'); background-size: cover;"></span>
Wood Dark
</button>
<button
@click="changeWoodPaneling('wood-mahogany'); mobileThemeOpen = false"
type="button"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
data-wood="wood-mahogany"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background: url('/static/textures/wood-mahogany.png'); background-size: cover;"></span>
Wood Mahogany
</button>
</div>
</div>
<div class="border-t my-2" style="border-color: var(--border);"></div>
if user.ID != "" {
<div class="space-y-1">
<div class="flex items-center space-x-2 px-3 py-2">
<svg class="h-5 w-5" style="color: var(--text-secondary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg>
<span class="text-sm" style="color: var(--text-primary)">{ user.Username }</span>
</div>
<a href="/profile" class="block px-3 py-2 pl-10 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Profile
</a>
if user.Role == "admin" {
<a href="/admin" class="block px-3 py-2 pl-10 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Admin Panel
</a>
}
<button
@click="logout()"
type="button"
class="block w-full text-left px-3 py-2 pl-10 rounded-lg text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-secondary);"
>
Logout
</button>
</div>
} else {
<div class="space-y-3">
<form hx-post="/api/auth/login" hx-target="#login-result-mobile" hx-swap="innerHTML" class="space-y-3">
<input type="hidden" name="redirect" value="{ currentPath }"/>
<div>
<label class="block text-sm mb-1" style="color: var(--text-secondary)">Email or Username</label>
<input type="text" name="login" class="w-full px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" required/>
</div>
<div>
<label class="block text-sm mb-1" style="color: var(--text-secondary)">Password</label>
<input type="password" name="password" class="w-full px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" required/>
</div>
<button type="submit" class="w-full py-2 rounded text-sm" style="background-color: var(--accent); color: white;">
Sign In
</button>
</form>
<div id="login-result-mobile"></div>
<a href="/register" class="block text-center text-sm hover:opacity-80" style="color: var(--text-secondary); text-decoration: none;">
Don't have an account? Sign Up
</a>
</div>
}
</div>
</div>
</div>
</nav>
File diff suppressed because one or more lines are too long
+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