From ade810a0131262a7827ef2564a37cda8c7855011 Mon Sep 17 00:00:00 2001 From: "Marko (Hermes Implementer)" Date: Fri, 29 May 2026 05:10:06 +0000 Subject: [PATCH] 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