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
+26
View File
@@ -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;
+105
View File
@@ -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<string | null>(null);
const recognitionRef = useRef<SpeechRecognition | null>(null);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
// Check for SpeechRecognition support (standard + webkit prefix)
const SpeechRecognitionCtor =
(window as unknown as Record<string, unknown>).SpeechRecognition ??
(window as unknown as Record<string, unknown>).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,
};
}