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:
Marko (Hermes Implementer)
2026-05-29 05:59:10 +00:00
102 changed files with 4400 additions and 2847 deletions
+4 -1
View File
@@ -1 +1,4 @@
export { usePaginatedQuery } from "./usePaginatedQuery";
export { usePaginatedQuery } from "./usePaginatedQuery";
export { useDebounce } from "./useDebounce";
export { useVoiceSearch } from "./useVoiceSearch";
export { useMediaQuery } from "./useMediaQuery";
+18
View File
@@ -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;
}
+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,
};
}