From 658f77a746f8b465ee238f96fcf87c8649e31ea7 Mon Sep 17 00:00:00 2001 From: "Marko (Hermes Implementer)" Date: Fri, 29 May 2026 02:55:50 +0000 Subject: [PATCH 1/2] feat: mobile book search and discovery with voice search, suggestions, and responsive layout --- docs/frontend/mobile-search-spec.md | 99 +++++ frontend/src/api/books.ts | 32 +- .../components/search/SearchSuggestions.tsx | 166 ++++++++ frontend/src/hooks/useMediaQuery.ts | 26 ++ frontend/src/hooks/useVoiceSearch.ts | 105 +++++ frontend/src/pages/BookDetailPage.tsx | 119 ++++-- frontend/src/pages/Library.tsx | 387 +++++++++++++++--- frontend/src/types/book.ts | 20 +- 8 files changed, 854 insertions(+), 100 deletions(-) create mode 100644 docs/frontend/mobile-search-spec.md create mode 100644 frontend/src/components/search/SearchSuggestions.tsx create mode 100644 frontend/src/hooks/useMediaQuery.ts create mode 100644 frontend/src/hooks/useVoiceSearch.ts diff --git a/docs/frontend/mobile-search-spec.md b/docs/frontend/mobile-search-spec.md new file mode 100644 index 0000000..c3938b7 --- /dev/null +++ b/docs/frontend/mobile-search-spec.md @@ -0,0 +1,99 @@ +# Mobile Book Search & Discovery — Spec + +## Overview +Enhance the existing book search experience with mobile-first features: voice search via the Web Speech API, real-time autocomplete suggestions, and touch-optimized responsive layout. + +## Prerequisites +- Backend endpoints already exist (from `docs/backend/search-discovery-spec.md`): + - `GET /api/books/?q=...&genre=...&author=...&reading_status=...` — paginated search + - `GET /api/books/{id}/` — book detail + - `GET /api/books/genres/` — genre discovery + - `GET /api/books/authors/` — author discovery +- Frontend `LibraryPage` and `BookDetailPage` components exist but lacked API client methods and types (fixed in this PR). + +## Frontend API Client Additions + +### `frontend/src/types/book.ts` — New exports + +```typescript +export interface BookSearchParams { + q?: string; + genre?: string; + author?: string; + reading_status?: string; + ordering?: string; + page?: number; + page_size?: number; +} + +export const READING_STATUS_OPTIONS: { value: string; label: string }[] = [ + { value: "", label: "All Statuses" }, + { value: "want_to_read", label: "Want to Read" }, + { value: "reading", label: "Reading" }, + { value: "finished", label: "Finished" }, + { value: "dnf", label: "Did Not Finish" }, +]; +``` + +### `frontend/src/api/books.ts` — New methods on `booksApi` + +| Method | Endpoint | Returns | +|--------|----------|---------| +| `searchBooks(params)` | `GET /api/books/` | `{ count, results: BookListItem[] }` | +| `getBook(id)` | `GET /api/books/{id}/` | `BookDetail` | +| `getGenres()` | `GET /api/books/genres/` | `string[]` | +| `getAuthors()` | `GET /api/books/authors/` | `string[]` | + +## Mobile Features + +### 1. Voice Search +- **Hook**: `useVoiceSearch` in `frontend/src/hooks/useVoiceSearch.ts` +- Uses the Web Speech API (`SpeechRecognition` / `webkitSpeechRecognition`) +- Returns: `{ isListening, transcript, isSupported, startListening, stopListening, hasError }` +- Renders a microphone icon button next to the search input +- On mobile, tapping the mic icon triggers the native speech recognition prompt +- On success, populates the search input with the transcript and triggers a search +- Graceful degradation: if SpeechRecognition API is unavailable, the mic button is hidden + +### 2. Real-Time Suggestions (Autocomplete) +- Component: `SearchSuggestions` rendered as a dropdown below the search input +- On each keystroke (debounced 200ms), fetches `GET /api/books/?q=...&page_size=5` for suggestions +- Shows up to 5 book title/author suggestions in a styled dropdown list +- Clicking a suggestion navigates directly to `/books/{id}` +- Clicking outside or pressing Escape dismisses the dropdown +- Combines with existing full search results — suggestions are fast previews, not the main result list + +### 3. Mobile-Responsive Enhancements +- Filters panel is **collapsed by default** on mobile, toggleable via a "Filters" button +- Touch targets minimum 44px (WCAG 2.1) +- Results grid switches to **single column** below 600px viewport width +- Search input and filters panel stack vertically on small screens +- Add CSS breakpoints via inline styles and a `useMediaQuery` hook +- Bottom navigation-style action buttons on mobile (Add Book, Bookmarks, Settings become icon-only) + +## Component Hierarchy + +``` +LibraryPage +├── Header (title, count, action buttons) +├── SearchInput +│ ├── TextInput (debounced 300ms) +│ ├── VoiceSearchButton (microphone icon) +│ └── SearchSuggestions (dropdown, debounced 200ms) +├── FiltersButton (mobile: toggle; desktop: always visible) +├── FiltersPanel (collapsible on mobile) +│ ├── GenreSelect +│ ├── AuthorSelect +│ ├── StatusSelect +│ └── ClearFiltersButton +├── LoadingState (skeleton grid) +├── ErrorState (message + retry button) +├── EmptyState (no results / no books) +└── ResultsGrid (responsive: auto-fill vs single column) +``` + +## Mobile-First CSS Strategy +- Use inline styles with `@media` queries in a shared `breakpoints.ts` utility +- Breakpoints: sm = 480px, md = 768px, lg = 1024px +- Base styles are mobile-first (single column, full width) +- Media queries expand to multi-column grid and horizontal layout on larger screens \ No newline at end of file diff --git a/frontend/src/api/books.ts b/frontend/src/api/books.ts index 5d65379..1ab0c79 100644 --- a/frontend/src/api/books.ts +++ b/frontend/src/api/books.ts @@ -1,5 +1,15 @@ import api from "./client"; -import type { ContentResponse, EBookDetail, EBookListItem, ReadingProgress, ReadingSettings, TocResponse } from "../types/book"; +import type { + BookDetail, + BookListItem, + BookSearchParams, + ContentResponse, + EBookDetail, + EBookListItem, + ReadingProgress, + ReadingSettings, + TocResponse, +} from "../types/book"; export const booksApi = { async getEBooks(): Promise { @@ -7,6 +17,26 @@ export const booksApi = { return data; }, + async searchBooks(params: BookSearchParams = {}): Promise<{ count: number; results: BookListItem[] }> { + const { data } = await api.get<{ count: number; results: BookListItem[] }>("/books/", { params }); + return data; + }, + + async getBook(id: number): Promise { + const { data } = await api.get(`/books/${id}/`); + return data; + }, + + async getGenres(): Promise { + const { data } = await api.get("/books/genres/"); + return data; + }, + + async getAuthors(): Promise { + const { data } = await api.get("/books/authors/"); + return data; + }, + async getEBook(id: number): Promise { const { data } = await api.get(`/books/ebooks/${id}/`); return data; diff --git a/frontend/src/components/search/SearchSuggestions.tsx b/frontend/src/components/search/SearchSuggestions.tsx new file mode 100644 index 0000000..c6c7430 --- /dev/null +++ b/frontend/src/components/search/SearchSuggestions.tsx @@ -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(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([]); + const [loading, setLoading] = useState(false); + const containerRef = useRef(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 ( +
+ {loading && ( +
+ Searching... +
+ )} + {!loading && suggestions.length === 0 && debouncedQuery.trim() && ( +
+ No quick suggestions +
+ )} + {suggestions.map((book) => ( +
{ + 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 = ""; + }} + > + + {book.cover_image ? ( + + ) : ( + "📖" + )} + +
+
+ {book.title} +
+
+ {book.author || "Unknown Author"} +
+
+
+ ))} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/hooks/useMediaQuery.ts b/frontend/src/hooks/useMediaQuery.ts new file mode 100644 index 0000000..36e2843 --- /dev/null +++ b/frontend/src/hooks/useMediaQuery.ts @@ -0,0 +1,26 @@ +import { useEffect, useState } from "react"; + +/** + * Hook for responsive design — returns true when the media query matches. + * Defaults to false on SSR / initial render to avoid hydration mismatch. + */ +export function useMediaQuery(query: string): boolean { + const [matches, setMatches] = useState(false); + + useEffect(() => { + const mql = window.matchMedia(query); + setMatches(mql.matches); + + const handler = (e: MediaQueryListEvent) => setMatches(e.matches); + mql.addEventListener("change", handler); + return () => mql.removeEventListener("change", handler); + }, [query]); + + return matches; +} + +export const BREAKPOINTS = { + sm: "(max-width: 480px)", + md: "(max-width: 768px)", + lg: "(min-width: 1024px)", +} as const; \ No newline at end of file diff --git a/frontend/src/hooks/useVoiceSearch.ts b/frontend/src/hooks/useVoiceSearch.ts new file mode 100644 index 0000000..c6e43c4 --- /dev/null +++ b/frontend/src/hooks/useVoiceSearch.ts @@ -0,0 +1,105 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +export interface UseVoiceSearchResult { + isListening: boolean; + transcript: string; + isSupported: boolean; + hasError: boolean; + errorMessage: string | null; + startListening: () => void; + stopListening: () => void; +} + +/** + * Hook for voice search using the Web Speech API. + * Returns a microphone control interface. + * Gracefully degrades when SpeechRecognition is unavailable. + */ +export function useVoiceSearch(): UseVoiceSearchResult { + const [isListening, setIsListening] = useState(false); + const [transcript, setTranscript] = useState(""); + const [isSupported, setIsSupported] = useState(false); + const [hasError, setHasError] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + const recognitionRef = useRef(null); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + // Check for SpeechRecognition support (standard + webkit prefix) + const SpeechRecognitionCtor = + (window as unknown as Record).SpeechRecognition ?? + (window as unknown as Record).webkitSpeechRecognition; + + if (typeof SpeechRecognitionCtor === "function") { + setIsSupported(true); + const recognition = new (SpeechRecognitionCtor as new () => SpeechRecognition)(); + recognition.continuous = false; + recognition.interimResults = false; + recognition.lang = "en-US"; + + recognition.onresult = (event: SpeechRecognitionEvent) => { + const resultText = event.results[0]?.[0]?.transcript ?? ""; + if (mountedRef.current) { + setTranscript(resultText); + setHasError(false); + setErrorMessage(null); + } + }; + + recognition.onerror = (event: SpeechRecognitionErrorEvent) => { + if (mountedRef.current) { + setHasError(true); + setErrorMessage(event.error); + setIsListening(false); + } + }; + + recognition.onend = () => { + if (mountedRef.current) { + setIsListening(false); + } + }; + + recognitionRef.current = recognition; + } + + return () => { + mountedRef.current = false; + if (recognitionRef.current) { + recognitionRef.current.abort(); + } + }; + }, []); + + const startListening = useCallback(() => { + if (!recognitionRef.current) return; + setTranscript(""); + setHasError(false); + setErrorMessage(null); + try { + recognitionRef.current.start(); + setIsListening(true); + } catch { + // May throw if already started + setIsListening(false); + } + }, []); + + const stopListening = useCallback(() => { + if (!recognitionRef.current) return; + recognitionRef.current.stop(); + setIsListening(false); + }, []); + + return { + isListening, + transcript, + isSupported, + hasError, + errorMessage, + startListening, + stopListening, + }; +} \ No newline at end of file diff --git a/frontend/src/pages/BookDetailPage.tsx b/frontend/src/pages/BookDetailPage.tsx index 489513b..a21d316 100644 --- a/frontend/src/pages/BookDetailPage.tsx +++ b/frontend/src/pages/BookDetailPage.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { booksApi } from "../api/books"; import type { BookDetail } from "../types/book"; +import { useMediaQuery, BREAKPOINTS } from "../hooks/useMediaQuery"; export function BookDetailPage() { const { id } = useParams<{ id: string }>(); @@ -9,6 +10,7 @@ export function BookDetailPage() { const [book, setBook] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const isMobile = useMediaQuery(BREAKPOINTS.md); const loadBook = useCallback(async () => { if (!id) return; @@ -40,12 +42,42 @@ export function BookDetailPage() { dnf: { bg: "#fef3c7", text: "#b45309" }, }; + const containerStyle: React.CSSProperties = { + maxWidth: 720, + margin: "0 auto", + padding: isMobile ? 16 : 24, + minHeight: "100vh", + background: "#f8f9fa", + }; + + const backButtonStyle: React.CSSProperties = { + display: "inline-flex", + alignItems: "center", + gap: 4, + padding: isMobile ? "10px 16px" : "8px 16px", + borderRadius: 8, + border: "1px solid #e5e7eb", + background: "#fff", + color: "#374151", + fontSize: isMobile ? 15 : 14, + cursor: "pointer", + marginBottom: isMobile ? 16 : 24, + minHeight: 44, + }; + if (loading) { return ( -
+
-
-
+
+
@@ -61,13 +93,13 @@ export function BookDetailPage() { if (error || !book) { return ( -
- -
-
😕
-

Book not found

+
+ +
+
😕
+

Book not found

{error || "The book you're looking for doesn't exist or has been removed."}

- +
); @@ -76,57 +108,56 @@ export function BookDetailPage() { const sc = statusColors[book.reading_status] ?? { bg: "#f3f4f6", text: "#6b7280" }; return ( -
+
{/* Back button */} - {/* Book Detail */} -
+
{/* Cover */} -
+
{book.cover_image ? {book.title} - : 📖} + : 📖}
{/* Info */} -
-

+
+

{book.title}

{book.author && ( -

+

by {book.author}

)}
- + {book.reading_status_display} {book.genre && ( - + {book.genre} )} {book.total_pages > 0 && ( - + {book.total_pages} pages )} @@ -135,7 +166,7 @@ export function BookDetailPage() { {book.description && (

Description

-

+

{book.description}

@@ -148,10 +179,28 @@ export function BookDetailPage() {

- {/* Mobile-only: open in reader if it's an ebook, or just navigate back */} -
- -
+ {/* Mobile full-width back button */} + {isMobile && ( +
+ +
+ )}

diff --git a/frontend/src/pages/Library.tsx b/frontend/src/pages/Library.tsx index cc923f6..7b45d95 100644 --- a/frontend/src/pages/Library.tsx +++ b/frontend/src/pages/Library.tsx @@ -4,6 +4,9 @@ import { booksApi } from "../api/books"; import type { BookListItem, BookSearchParams } from "../types/book"; import { READING_STATUS_OPTIONS } from "../types/book"; import { useAuth } from "../context/AuthContext"; +import { useVoiceSearch } from "../hooks/useVoiceSearch"; +import { useMediaQuery, BREAKPOINTS } from "../hooks/useMediaQuery"; +import { SearchSuggestions } from "../components/search/SearchSuggestions"; interface FilterState { genre: string; @@ -20,6 +23,15 @@ function useDebounce(value: T, delay: number): T { return debounced; } +/** WCAG 2.1 minimum touch target */ +const TOUCH_TARGET: React.CSSProperties = { + minHeight: 44, + minWidth: 44, + display: "inline-flex", + alignItems: "center", + justifyContent: "center", +}; + export function LibraryPage() { const navigate = useNavigate(); const { logout } = useAuth(); @@ -33,9 +45,23 @@ export function LibraryPage() { const [authors, setAuthors] = useState([]); const [totalCount, setTotalCount] = useState(0); const [showFilters, setShowFilters] = useState(false); + const [showSuggestions, setShowSuggestions] = useState(false); const debouncedSearch = useDebounce(searchQuery, 300); const loadedRef = useRef(false); + const searchInputRef = useRef(null); + const isMobile = useMediaQuery(BREAKPOINTS.md); + + // Voice search + const voiceSearch = useVoiceSearch(); + + // Sync voice transcript into search input + useEffect(() => { + if (voiceSearch.transcript && !voiceSearch.isListening) { + setSearchQuery(voiceSearch.transcript); + setShowSuggestions(false); + } + }, [voiceSearch.transcript, voiceSearch.isListening]); // Load filter options once useEffect(() => { @@ -79,114 +105,303 @@ export function LibraryPage() { }, [debouncedSearch, filters, loadBooks]); const handleFilterChange = (key: keyof FilterState, value: string) => { - setFilters((prev) => ({ ...prev, [key]: value })); + setFilters((prev: FilterState) => ({ ...prev, [key]: value })); }; const clearAllFilters = () => { setSearchQuery(""); setFilters({ genre: "", author: "", reading_status: "" }); + setShowFilters(false); }; const hasActiveFilters = !!searchQuery || !!filters.genre || !!filters.author || !!filters.reading_status; + const containerStyle: React.CSSProperties = { + maxWidth: 960, + margin: "0 auto", + padding: isMobile ? 12 : 16, + minHeight: "100vh", + background: "#f8f9fa", + }; + + const headerStyle: React.CSSProperties = { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + marginBottom: isMobile ? 12 : 20, + padding: isMobile ? "12px 0" : "16px 0", + borderBottom: "1px solid #e5e7eb", + flexWrap: "wrap", + gap: 8, + }; + + const searchContainerStyle: React.CSSProperties = { + position: "relative", + flex: 1, + }; + return ( -
+
{/* Header */} -
+
-

Library

- {!loading &&

{totalCount} book{totalCount !== 1 ? "s" : ""}

} +

Library

+ {!loading && ( +

+ {totalCount} book{totalCount !== 1 ? "s" : ""} +

+ )}
-
- - - - +
+ {isMobile ? ( + <> + + + + + + ) : ( + <> + + + + + + )}
{/* Search Bar */}
-
+
setSearchQuery(e.target.value)} + onChange={(e) => { + setSearchQuery(e.target.value); + setShowSuggestions(true); + }} + onFocus={() => setShowSuggestions(true)} style={{ - width: "100%", padding: "12px 16px 12px 44px", borderRadius: 10, - border: "1px solid #e5e7eb", fontSize: 15, background: "#fff", - outline: "none", boxSizing: "border-box", + width: "100%", + padding: `12px 16px 12px ${voiceSearch.isSupported ? 44 : 44}px`, + paddingRight: voiceSearch.isSupported ? 48 : 16, + borderRadius: 10, + border: "1px solid #e5e7eb", + fontSize: isMobile ? 16 : 15, + background: "#fff", + outline: "none", + boxSizing: "border-box", + minHeight: 44, }} /> 🔍 + + {/* Voice Search Button */} + {voiceSearch.isSupported && ( + + )} + + {/* Real-time Suggestions */} + setShowSuggestions(false)} + onSelectSuggestion={() => setShowSuggestions(false)} + />
+ {/* Voice search listening indicator */} + {voiceSearch.isListening && ( +
+ + Listening... speak now + +
+ )} + + {/* Voice search error */} + {voiceSearch.hasError && !voiceSearch.isListening && ( +
+ Voice search: {voiceSearch.errorMessage === "no-speech" ? "No speech detected. Try again." : voiceSearch.errorMessage} +
+ )} + {/* Filters Panel */} {showFilters && (
-
+
-
+
-
+
{hasActiveFilters && ( - )} @@ -197,16 +412,21 @@ export function LibraryPage() { {error && (

{error}

- +
)} {/* Loading State */} {loading && ( -
- {Array.from({ length: 8 }).map((_, i) => ( -
-
+
+ {Array.from({ length: isMobile ? 4 : 8 }).map((_, i) => ( +
+
@@ -218,9 +438,9 @@ export function LibraryPage() { {/* Empty State */} {!loading && !error && books.length === 0 && ( -
-
{hasActiveFilters ? "🔍" : "📚"}
-

+
+
{hasActiveFilters ? "🔍" : "📚"}
+

{hasActiveFilters ? "No books found" : "Your library is empty"}

@@ -229,11 +449,11 @@ export function LibraryPage() { : "Add a book to get started building your collection."}

{hasActiveFilters ? ( - ) : ( - )} @@ -242,7 +462,11 @@ export function LibraryPage() { {/* Results Grid */} {!loading && books.length > 0 && ( -
+
{books.map((book) => { const statusColors: Record = { want_to_read: { bg: "#dbeafe", text: "#1d4ed8" }, @@ -257,9 +481,14 @@ export function LibraryPage() { key={book.id} onClick={() => navigate(`/books/${book.id}`)} style={{ - background: "#fff", borderRadius: 12, overflow: "hidden", - boxShadow: "0 2px 8px rgba(0,0,0,0.06)", cursor: "pointer", + background: "#fff", + borderRadius: 12, + overflow: "hidden", + boxShadow: "0 2px 8px rgba(0,0,0,0.06)", + cursor: "pointer", transition: "transform 0.15s, box-shadow 0.15s", + display: isMobile ? "flex" : "block", + minHeight: isMobile ? undefined : undefined, }} onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.transform = "translateY(-2px)"; @@ -270,27 +499,59 @@ export function LibraryPage() { (e.currentTarget as HTMLElement).style.boxShadow = "0 2px 8px rgba(0,0,0,0.06)"; }} > -
+
{book.cover_image ? {book.title} - : 📖} - - {book.reading_status_display} - + : 📖} + {!isMobile && ( + + {book.reading_status_display} + + )}
-
-

- {book.title} -

-

- {book.author || "Unknown Author"} -

+
+
+
+

+ {book.title} +

+

+ {book.author || "Unknown Author"} +

+
+ {isMobile && ( + + {book.reading_status_display} + + )} +
{book.genre && ( - + {book.genre} )} diff --git a/frontend/src/types/book.ts b/frontend/src/types/book.ts index b81a169..c45ef41 100644 --- a/frontend/src/types/book.ts +++ b/frontend/src/types/book.ts @@ -81,4 +81,22 @@ export interface ContentResponse { content: string; chapter_title: string; format: string; -} \ No newline at end of file +} + +export interface BookSearchParams { + q?: string; + genre?: string; + author?: string; + reading_status?: string; + ordering?: string; + page?: number; + page_size?: number; +} + +export const READING_STATUS_OPTIONS: { value: string; label: string }[] = [ + { value: "", label: "All Statuses" }, + { value: "want_to_read", label: "Want to Read" }, + { value: "reading", label: "Reading" }, + { value: "finished", label: "Finished" }, + { value: "dnf", label: "Did Not Finish" }, +]; \ No newline at end of file -- 2.54.0 From ade810a0131262a7827ef2564a37cda8c7855011 Mon Sep 17 00:00:00 2001 From: "Marko (Hermes Implementer)" Date: Fri, 29 May 2026 05:10:06 +0000 Subject: [PATCH 2/2] fix: address PR #24 review comments - SpeechRecognition types, CSS hover over direct DOM, shared useDebounce --- .../search/SearchSuggestions.module.css | 65 +++++++++++++++++ .../components/search/SearchSuggestions.tsx | 70 ++++--------------- frontend/src/hooks/index.ts | 5 +- frontend/src/hooks/useDebounce.ts | 18 +++++ frontend/src/pages/Library.module.css | 13 ++++ frontend/src/pages/Library.tsx | 27 +------ frontend/src/types/speech-recognition.d.ts | 54 ++++++++++++++ 7 files changed, 169 insertions(+), 83 deletions(-) create mode 100644 frontend/src/components/search/SearchSuggestions.module.css create mode 100644 frontend/src/hooks/useDebounce.ts create mode 100644 frontend/src/pages/Library.module.css create mode 100644 frontend/src/types/speech-recognition.d.ts diff --git a/frontend/src/components/search/SearchSuggestions.module.css b/frontend/src/components/search/SearchSuggestions.module.css new file mode 100644 index 0000000..3d71127 --- /dev/null +++ b/frontend/src/components/search/SearchSuggestions.module.css @@ -0,0 +1,65 @@ +.container { + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 100; + background: #fff; + border: 1px solid #e5e7eb; + border-top: none; + border-radius: 0 0 10px 10px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + max-height: 320px; + overflow-y: auto; +} + +.infoText { + padding: 12px 16px; + color: #9ca3af; + font-size: 13px; +} + +.suggestionItem { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + cursor: pointer; + border-bottom: 1px solid #f3f4f6; + min-height: 44px; +} + +.suggestionItem:hover { + background: #f9fafb; +} + +.coverImage { + width: 32px; + height: 48px; + object-fit: cover; + border-radius: 4px; +} + +.coverPlaceholder { + font-size: 20px; + flex-shrink: 0; +} + +.bookInfo { + min-width: 0; +} + +.bookTitle { + font-size: 14px; + font-weight: 600; + color: #1f2937; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bookAuthor { + font-size: 12px; + color: #6b7280; + margin-top: 2px; +} \ No newline at end of file diff --git a/frontend/src/components/search/SearchSuggestions.tsx b/frontend/src/components/search/SearchSuggestions.tsx index c6c7430..136e24b 100644 --- a/frontend/src/components/search/SearchSuggestions.tsx +++ b/frontend/src/components/search/SearchSuggestions.tsx @@ -1,7 +1,9 @@ -import React, { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { booksApi } from "../../api/books"; +import { useDebounce } from "../../hooks/useDebounce"; import type { BookListItem } from "../../types/book"; +import styles from "./SearchSuggestions.module.css"; interface SearchSuggestionsProps { query: string; @@ -10,15 +12,6 @@ interface SearchSuggestionsProps { onSelectSuggestion: () => void; } -function useDebounce(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([]); @@ -81,81 +74,42 @@ export function SearchSuggestions({ query, visible, onClose, onSelectSuggestion if (!visible || !query.trim()) return null; return ( -
+
{loading && ( -
+
Searching...
)} {!loading && suggestions.length === 0 && debouncedQuery.trim() && ( -
+
No quick suggestions
)} {suggestions.map((book) => (
{ 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 = ""; - }} > - + {book.cover_image ? ( ) : ( "📖" )} -
-
+
+
{book.title}
-
+
{book.author || "Unknown Author"}
diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index 8a33b83..24a0685 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -1 +1,4 @@ -export { usePaginatedQuery } from "./usePaginatedQuery"; \ No newline at end of file +export { usePaginatedQuery } from "./usePaginatedQuery"; +export { useDebounce } from "./useDebounce"; +export { useVoiceSearch } from "./useVoiceSearch"; +export { useMediaQuery } from "./useMediaQuery"; \ No newline at end of file diff --git a/frontend/src/hooks/useDebounce.ts b/frontend/src/hooks/useDebounce.ts new file mode 100644 index 0000000..531a177 --- /dev/null +++ b/frontend/src/hooks/useDebounce.ts @@ -0,0 +1,18 @@ +import { useEffect, useState } from "react"; + +/** + * A hook that debounces a value by the specified delay. + * @param value - The value to debounce + * @param delay - The delay in milliseconds + * @returns The debounced value + */ +export function useDebounce(value: T, delay: number): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debounced; +} \ No newline at end of file diff --git a/frontend/src/pages/Library.module.css b/frontend/src/pages/Library.module.css new file mode 100644 index 0000000..efb786e --- /dev/null +++ b/frontend/src/pages/Library.module.css @@ -0,0 +1,13 @@ +.bookCard { + background: #fff; + border-radius: 12px; + overflow: hidden; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); + cursor: pointer; + transition: transform 0.15s, box-shadow 0.15s; +} + +.bookCard:hover { + transform: translateY(-2px); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); +} \ No newline at end of file diff --git a/frontend/src/pages/Library.tsx b/frontend/src/pages/Library.tsx index 7b45d95..dee2a62 100644 --- a/frontend/src/pages/Library.tsx +++ b/frontend/src/pages/Library.tsx @@ -4,9 +4,11 @@ import { booksApi } from "../api/books"; import type { BookListItem, BookSearchParams } from "../types/book"; import { READING_STATUS_OPTIONS } from "../types/book"; import { useAuth } from "../context/AuthContext"; +import { useDebounce } from "../hooks/useDebounce"; import { useVoiceSearch } from "../hooks/useVoiceSearch"; import { useMediaQuery, BREAKPOINTS } from "../hooks/useMediaQuery"; import { SearchSuggestions } from "../components/search/SearchSuggestions"; +import styles from "./Library.module.css"; interface FilterState { genre: string; @@ -14,15 +16,6 @@ interface FilterState { reading_status: string; } -function useDebounce(value: T, delay: number): T { - const [debounced, setDebounced] = useState(value); - useEffect(() => { - const timer = setTimeout(() => setDebounced(value), delay); - return () => clearTimeout(timer); - }, [value, delay]); - return debounced; -} - /** WCAG 2.1 minimum touch target */ const TOUCH_TARGET: React.CSSProperties = { minHeight: 44, @@ -480,23 +473,9 @@ export function LibraryPage() {
navigate(`/books/${book.id}`)} + className={styles.bookCard} style={{ - background: "#fff", - borderRadius: 12, - overflow: "hidden", - boxShadow: "0 2px 8px rgba(0,0,0,0.06)", - cursor: "pointer", - transition: "transform 0.15s, box-shadow 0.15s", display: isMobile ? "flex" : "block", - minHeight: isMobile ? undefined : undefined, - }} - onMouseEnter={(e) => { - (e.currentTarget as HTMLElement).style.transform = "translateY(-2px)"; - (e.currentTarget as HTMLElement).style.boxShadow = "0 4px 16px rgba(0,0,0,0.1)"; - }} - onMouseLeave={(e) => { - (e.currentTarget as HTMLElement).style.transform = ""; - (e.currentTarget as HTMLElement).style.boxShadow = "0 2px 8px rgba(0,0,0,0.06)"; }} >
void) | null; + onerror: ((event: SpeechRecognitionErrorEvent) => void) | null; + onend: (() => void) | null; + start(): void; + stop(): void; + abort(): void; +} + +interface SpeechRecognitionEvent extends Event { + readonly resultIndex: number; + readonly results: SpeechRecognitionResultList; +} + +interface SpeechRecognitionResultList { + readonly length: number; + [index: number]: SpeechRecognitionResult; +} + +interface SpeechRecognitionResult { + readonly isFinal: boolean; + readonly length: number; + [index: number]: SpeechRecognitionAlternative; +} + +interface SpeechRecognitionAlternative { + readonly transcript: string; + readonly confidence: number; +} + +interface SpeechRecognitionErrorEvent extends Event { + readonly error: string; + readonly message: string; +} + +interface SpeechRecognitionConstructor { + new (): SpeechRecognition; +} + +interface Window { + SpeechRecognition?: SpeechRecognitionConstructor; + webkitSpeechRecognition?: SpeechRecognitionConstructor; +} \ No newline at end of file -- 2.54.0