feat: mobile book search and discovery with voice search, suggestions, and responsive layout

This commit is contained in:
Marko (Hermes Implementer)
2026-05-29 02:55:50 +00:00
parent 332b539880
commit 658f77a746
8 changed files with 854 additions and 100 deletions
@@ -0,0 +1,166 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { booksApi } from "../../api/books";
import type { BookListItem } from "../../types/book";
interface SearchSuggestionsProps {
query: string;
visible: boolean;
onClose: () => void;
onSelectSuggestion: () => void;
}
function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
export function SearchSuggestions({ query, visible, onClose, onSelectSuggestion }: SearchSuggestionsProps) {
const navigate = useNavigate();
const [suggestions, setSuggestions] = useState<BookListItem[]>([]);
const [loading, setLoading] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const debouncedQuery = useDebounce(query, 200);
useEffect(() => {
if (!debouncedQuery.trim()) {
setSuggestions([]);
return;
}
let cancelled = false;
setLoading(true);
void booksApi.searchBooks({ q: debouncedQuery.trim(), page_size: 5 }).then(
(res) => {
if (!cancelled) {
setSuggestions(res.results);
setLoading(false);
}
},
() => {
if (!cancelled) {
setSuggestions([]);
setLoading(false);
}
},
);
return () => {
cancelled = true;
};
}, [debouncedQuery]);
// Close on click outside
useEffect(() => {
if (!visible) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
onClose();
}
};
// Delay attachment to avoid the click that opened us from immediately closing
const timer = setTimeout(() => document.addEventListener("click", handler), 0);
return () => {
clearTimeout(timer);
document.removeEventListener("click", handler);
};
}, [visible, onClose]);
// Close on Escape
useEffect(() => {
if (!visible) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [visible, onClose]);
if (!visible || !query.trim()) return null;
return (
<div
ref={containerRef}
style={{
position: "absolute",
top: "100%",
left: 0,
right: 0,
zIndex: 100,
background: "#fff",
border: "1px solid #e5e7eb",
borderTop: "none",
borderRadius: "0 0 10px 10px",
boxShadow: "0 8px 24px rgba(0,0,0,0.12)",
maxHeight: 320,
overflowY: "auto",
}}
>
{loading && (
<div style={{ padding: "12px 16px", color: "#9ca3af", fontSize: 13 }}>
Searching...
</div>
)}
{!loading && suggestions.length === 0 && debouncedQuery.trim() && (
<div style={{ padding: "12px 16px", color: "#9ca3af", fontSize: 13 }}>
No quick suggestions
</div>
)}
{suggestions.map((book) => (
<div
key={book.id}
onClick={() => {
onSelectSuggestion();
navigate(`/books/${book.id}`);
}}
style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: "10px 16px",
cursor: "pointer",
borderBottom: "1px solid #f3f4f6",
minHeight: 44,
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background = "#f9fafb";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = "";
}}
>
<span style={{ fontSize: 20, flexShrink: 0 }}>
{book.cover_image ? (
<img
src={book.cover_image}
alt=""
style={{ width: 32, height: 48, objectFit: "cover", borderRadius: 4 }}
/>
) : (
"📖"
)}
</span>
<div style={{ minWidth: 0 }}>
<div
style={{
fontSize: 14,
fontWeight: 600,
color: "#1f2937",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{book.title}
</div>
<div style={{ fontSize: 12, color: "#6b7280", marginTop: 2 }}>
{book.author || "Unknown Author"}
</div>
</div>
</div>
))}
</div>
);
}