diff --git a/src/app/api/komga/search/route.ts b/src/app/api/komga/search/route.ts new file mode 100644 index 0000000..2271ec6 --- /dev/null +++ b/src/app/api/komga/search/route.ts @@ -0,0 +1,72 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { SearchService } from "@/lib/services/search.service"; +import { AppError, getErrorMessage } from "@/utils/errors"; +import { ERROR_CODES } from "@/constants/errorCodes"; + +const MIN_QUERY_LENGTH = 2; +const DEFAULT_LIMIT = 6; +const MAX_LIMIT = 10; + +export async function GET(request: NextRequest) { + try { + const query = request.nextUrl.searchParams.get("q")?.trim() ?? ""; + const limitParam = request.nextUrl.searchParams.get("limit"); + const parsedLimit = limitParam ? Number(limitParam) : Number.NaN; + const limit = Number.isFinite(parsedLimit) + ? Math.max(1, Math.min(parsedLimit, MAX_LIMIT)) + : DEFAULT_LIMIT; + + if (query.length < MIN_QUERY_LENGTH) { + return NextResponse.json({ series: [], books: [] }, { headers: { "Cache-Control": "no-store" } }); + } + + const results = await SearchService.globalSearch(query, limit); + + return NextResponse.json( + { + series: results.series.map((series) => ({ + id: series.id, + title: series.metadata.title, + libraryId: series.libraryId, + booksCount: series.booksCount, + href: `/series/${series.id}`, + coverUrl: `/api/komga/images/series/${series.id}/thumbnail`, + })), + books: results.books.map((book) => ({ + id: book.id, + title: book.metadata.title || book.name, + seriesTitle: book.seriesTitle, + seriesId: book.seriesId, + href: `/books/${book.id}`, + coverUrl: `/api/komga/images/books/${book.id}/thumbnail`, + })), + }, + { headers: { "Cache-Control": "no-store" } } + ); + } catch (error) { + if (error instanceof AppError) { + return NextResponse.json( + { + error: { + code: error.code, + name: "Search fetch error", + message: getErrorMessage(error.code), + }, + }, + { status: 500 } + ); + } + + return NextResponse.json( + { + error: { + code: ERROR_CODES.SERIES.FETCH_ERROR, + name: "Search fetch error", + message: getErrorMessage(ERROR_CODES.SERIES.FETCH_ERROR), + }, + }, + { status: 500 } + ); + } +} diff --git a/src/components/layout/GlobalSearch.tsx b/src/components/layout/GlobalSearch.tsx new file mode 100644 index 0000000..f87217f --- /dev/null +++ b/src/components/layout/GlobalSearch.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { Input } from "@/components/ui/input"; +import { Search, BookOpen, Library } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect, useMemo, useRef, useState, type FormEvent } from "react"; +import { useTranslate } from "@/hooks/useTranslate"; +import { getImageUrl } from "@/lib/utils/image-url"; + +interface SearchSeriesResult { + id: string; + title: string; + href: string; + booksCount: number; +} + +interface SearchBookResult { + id: string; + title: string; + seriesTitle: string; + href: string; +} + +interface SearchResponse { + series: SearchSeriesResult[]; + books: SearchBookResult[]; +} + +const MIN_QUERY_LENGTH = 2; + +export function GlobalSearch() { + const { t } = useTranslate(); + const router = useRouter(); + const containerRef = useRef(null); + const abortControllerRef = useRef(null); + + const [query, setQuery] = useState(""); + const [isOpen, setIsOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [results, setResults] = useState({ series: [], books: [] }); + + const hasResults = results.series.length > 0 || results.books.length > 0; + + const firstResultHref = useMemo(() => { + if (results.series.length > 0) { + return results.series[0].href; + } + + if (results.books.length > 0) { + return results.books[0].href; + } + + return null; + }, [results.books, results.series]); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (!containerRef.current?.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + + useEffect(() => { + return () => { + abortControllerRef.current?.abort(); + }; + }, []); + + useEffect(() => { + const trimmedQuery = query.trim(); + + if (trimmedQuery.length < MIN_QUERY_LENGTH) { + setResults({ series: [], books: [] }); + setIsLoading(false); + return; + } + + const timeoutId = setTimeout(async () => { + try { + abortControllerRef.current?.abort(); + const controller = new AbortController(); + abortControllerRef.current = controller; + + setIsLoading(true); + + const response = await fetch(`/api/komga/search?q=${encodeURIComponent(trimmedQuery)}`, { + method: "GET", + signal: controller.signal, + cache: "no-store", + }); + + if (!response.ok) { + throw new Error("Search request failed"); + } + + const data = (await response.json()) as SearchResponse; + setResults(data); + setIsOpen(true); + } catch (error) { + if ((error as Error).name !== "AbortError") { + setResults({ series: [], books: [] }); + } + } finally { + setIsLoading(false); + } + }, 250); + + return () => { + clearTimeout(timeoutId); + }; + }, [query]); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + + if (firstResultHref) { + setIsOpen(false); + router.push(firstResultHref); + } + }; + + return ( +
+
+ + { + if (query.trim().length >= MIN_QUERY_LENGTH) { + setIsOpen(true); + } + }} + onChange={(event) => setQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Escape") { + setIsOpen(false); + } + }} + placeholder={t("header.search.placeholder")} + aria-label={t("header.search.placeholder")} + className="h-10 rounded-full border-border/60 bg-background/65 pl-10 pr-10 text-sm shadow-sm focus-visible:ring-primary/40" + /> + {isLoading && ( +
+
+
+ )} + + + {isOpen && query.trim().length >= MIN_QUERY_LENGTH && ( +
+
+ {results.series.length > 0 && ( +
+
+ {t("header.search.series")} +
+ {results.series.map((item) => ( + setIsOpen(false)} + className="flex items-center gap-3 rounded-xl px-3 py-2.5 transition-colors hover:bg-accent" + aria-label={t("header.search.openSeries", { title: item.title })} + > + {item.title} +
+

{item.title}

+

+ + {t("series.books", { count: item.booksCount })} +

+
+ + ))} +
+ )} + + {results.books.length > 0 && ( +
+
+ {t("header.search.books")} +
+ {results.books.map((item) => ( + setIsOpen(false)} + className="flex items-center gap-3 rounded-xl px-3 py-2.5 transition-colors hover:bg-accent" + aria-label={t("header.search.openBook", { title: item.title })} + > + {item.title} +
+

{item.title}

+

+ + {item.seriesTitle} +

+
+ + ))} +
+ )} + + {!isLoading && !hasResults && ( +

{t("header.search.empty")}

+ )} +
+
+ )} +
+ ); +} diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 4d42d52..873da2e 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -1,9 +1,10 @@ -import { Menu, Moon, Sun, RefreshCw } from "lucide-react"; +import { Menu, Moon, Sun, RefreshCw, Search } from "lucide-react"; import { useTheme } from "next-themes"; import LanguageSelector from "@/components/LanguageSelector"; import { useTranslation } from "react-i18next"; import { IconButton } from "@/components/ui/icon-button"; import { useState } from "react"; +import { GlobalSearch } from "@/components/layout/GlobalSearch"; interface HeaderProps { onToggleSidebar: () => void; @@ -19,6 +20,7 @@ export function Header({ const { theme, setTheme } = useTheme(); const { t } = useTranslation(); const [isRefreshing, setIsRefreshing] = useState(false); + const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false); const toggleTheme = () => { setTheme(theme === "dark" ? "light" : "dark"); @@ -33,7 +35,7 @@ export function Header({ }; return ( -
+
+
+ +
+
+ {isMobileSearchOpen && ( +
+ +
+ )}
); } diff --git a/src/i18n/messages/en/common.json b/src/i18n/messages/en/common.json index cca5c1d..41c6239 100644 --- a/src/i18n/messages/en/common.json +++ b/src/i18n/messages/en/common.json @@ -454,6 +454,18 @@ "endOfSeriesMessage": "You have finished all the books in this series!", "backToSeries": "Back to series" }, + "header": { + "toggleSidebar": "Toggle sidebar", + "toggleTheme": "Toggle theme", + "search": { + "placeholder": "Search series and books...", + "empty": "No results", + "series": "Series", + "books": "Books", + "openSeries": "Open series {{title}}", + "openBook": "Open book {{title}}" + } + }, "navigation": { "scrollLeft": "Scroll left", "scrollRight": "Scroll right", diff --git a/src/i18n/messages/fr/common.json b/src/i18n/messages/fr/common.json index 1f9b159..604639b 100644 --- a/src/i18n/messages/fr/common.json +++ b/src/i18n/messages/fr/common.json @@ -454,7 +454,15 @@ }, "header": { "toggleSidebar": "Afficher/masquer le menu latéral", - "toggleTheme": "Changer le thème" + "toggleTheme": "Changer le thème", + "search": { + "placeholder": "Rechercher séries et tomes...", + "empty": "Aucun résultat", + "series": "Séries", + "books": "Tomes", + "openSeries": "Ouvrir la série {{title}}", + "openBook": "Ouvrir le tome {{title}}" + } }, "navigation": { "scrollLeft": "Défiler vers la gauche", diff --git a/src/lib/services/search.service.ts b/src/lib/services/search.service.ts new file mode 100644 index 0000000..22319e3 --- /dev/null +++ b/src/lib/services/search.service.ts @@ -0,0 +1,63 @@ +import type { KomgaBook, KomgaSeries } from "@/types/komga"; +import { ERROR_CODES } from "../../constants/errorCodes"; +import { AppError } from "../../utils/errors"; +import { BaseApiService } from "./base-api.service"; + +interface SearchResponse { + content: T[]; +} + +export interface GlobalSearchResult { + series: KomgaSeries[]; + books: KomgaBook[]; +} + +export class SearchService extends BaseApiService { + private static readonly CACHE_TTL = 30; + + static async globalSearch(query: string, limit: number = 6): Promise { + const trimmedQuery = query.trim(); + + if (!trimmedQuery) { + return { series: [], books: [] }; + } + + const headers = { "Content-Type": "application/json" }; + const searchBody = { + fullTextSearch: trimmedQuery, + }; + + try { + const [seriesResponse, booksResponse] = await Promise.all([ + this.fetchFromApi>( + { path: "series/list", params: { page: "0", size: String(limit) } }, + headers, + { + method: "POST", + body: JSON.stringify(searchBody), + revalidate: this.CACHE_TTL, + } + ), + this.fetchFromApi>( + { path: "books/list", params: { page: "0", size: String(limit) } }, + headers, + { + method: "POST", + body: JSON.stringify(searchBody), + revalidate: this.CACHE_TTL, + } + ), + ]); + + return { + series: seriesResponse.content.filter((item) => !item.deleted), + books: booksResponse.content.filter((item) => !item.deleted), + }; + } catch (error) { + if (error instanceof AppError) { + throw error; + } + throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error); + } + } +}