Archived
Merge branch 'main' into feature/customizable-mobile-reading-experience
Resolve merge conflicts: - backend/apps/books/: Keep main's models (EBook, BookChapter, etc.) - frontend/src/App.tsx: Keep /read/:id route + main's all routes - web/ files: Accept deletion (content ported to frontend/)
This commit is contained in:
@@ -1 +1,4 @@
|
||||
export { usePaginatedQuery } from "./usePaginatedQuery";
|
||||
export { usePaginatedQuery } from "./usePaginatedQuery";
|
||||
export { useDebounce } from "./useDebounce";
|
||||
export { useVoiceSearch } from "./useVoiceSearch";
|
||||
export { useMediaQuery } from "./useMediaQuery";
|
||||
@@ -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<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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user