diff --git a/src/api/generated/api.schemas.ts b/src/api/generated/api.schemas.ts index 72312cc..bce216f 100644 --- a/src/api/generated/api.schemas.ts +++ b/src/api/generated/api.schemas.ts @@ -215,9 +215,9 @@ export interface PageMangaImportJobDTO { number?: number; pageable?: PageableObject; numberOfElements?: number; - sort?: SortObject; first?: boolean; last?: boolean; + sort?: SortObject; empty?: boolean; } @@ -236,6 +236,28 @@ export interface SortObject { unsorted?: boolean; } +export interface DefaultResponseDTOListMangaProxyDataDTO { + timestamp?: string; + data?: MangaProxyDataDTO[]; + message?: string; +} + +export interface MangaProxyDataDTO { + imageUrl?: string; + /** @minLength 1 */ + title: string; + alternativeTitles: string[]; + authors: string[]; + publishedAt?: string; + id: number; +} + +export interface DefaultResponseDTOMangaProxyDataDTO { + timestamp?: string; + data?: MangaProxyDataDTO; + message?: string; +} + export interface DefaultResponseDTOPageMangaListDTO { timestamp?: string; data?: PageMangaListDTO; @@ -278,9 +300,9 @@ export interface PageMangaListDTO { number?: number; pageable?: PageableObject; numberOfElements?: number; - sort?: SortObject; first?: boolean; last?: boolean; + sort?: SortObject; empty?: boolean; } diff --git a/src/api/generated/catalog-proxy/catalog-proxy.ts b/src/api/generated/catalog-proxy/catalog-proxy.ts new file mode 100644 index 0000000..c1475f7 --- /dev/null +++ b/src/api/generated/catalog-proxy/catalog-proxy.ts @@ -0,0 +1,405 @@ +/** + * Generated by orval v7.17.0 ๐Ÿบ + * Do not edit manually. + * OpenAPI definition + * OpenAPI spec version: v0 + */ +import { + useQuery +} from '@tanstack/react-query'; +import type { + DataTag, + DefinedInitialDataOptions, + DefinedUseQueryResult, + QueryClient, + QueryFunction, + QueryKey, + UndefinedInitialDataOptions, + UseQueryOptions, + UseQueryResult +} from '@tanstack/react-query'; + +import type { + DefaultResponseDTOListMangaProxyDataDTO, + DefaultResponseDTOMangaProxyDataDTO +} from '../api.schemas'; + +import { customInstance } from '../../api'; + + +type SecondParameter unknown> = Parameters[1]; + + + +/** + * Fetches manga information from MyAnimeList using the provided title. This endpoint serves as a proxy to search for manga data without directly exposing the MyAnimeList API. + * @summary Get manga data from MyAnimeList by title + */ +export const searchMyAnimeListMangaDataByTitle = ( + title: string, + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customInstance( + {url: `/catalog/proxy/myanimelist/${encodeURIComponent(String(title))}/search`, method: 'GET', signal + }, + options); + } + + + + +export const getSearchMyAnimeListMangaDataByTitleQueryKey = (title?: string,) => { + return [ + `/catalog/proxy/myanimelist/${title}/search` + ] as const; + } + + +export const getSearchMyAnimeListMangaDataByTitleQueryOptions = >, TError = unknown>(title: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getSearchMyAnimeListMangaDataByTitleQueryKey(title); + + + + const queryFn: QueryFunction>> = ({ signal }) => searchMyAnimeListMangaDataByTitle(title, requestOptions, signal); + + + + + + return { queryKey, queryFn, enabled: !!(title), ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type SearchMyAnimeListMangaDataByTitleQueryResult = NonNullable>> +export type SearchMyAnimeListMangaDataByTitleQueryError = unknown + + +export function useSearchMyAnimeListMangaDataByTitle>, TError = unknown>( + title: string, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useSearchMyAnimeListMangaDataByTitle>, TError = unknown>( + title: string, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useSearchMyAnimeListMangaDataByTitle>, TError = unknown>( + title: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary Get manga data from MyAnimeList by title + */ + +export function useSearchMyAnimeListMangaDataByTitle>, TError = unknown>( + title: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getSearchMyAnimeListMangaDataByTitleQueryOptions(title,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey ; + + return query; +} + + + + +/** + * Fetches manga information from MyAnimeList using the provided ID. This endpoint serves as a proxy to retrieve manga data without directly exposing the MyAnimeList API. + * @summary Get manga data from MyAnimeList by ID + */ +export const getMyAnimeListMangaDataById = ( + id: number, + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customInstance( + {url: `/catalog/proxy/myanimelist/${encodeURIComponent(String(id))}`, method: 'GET', signal + }, + options); + } + + + + +export const getGetMyAnimeListMangaDataByIdQueryKey = (id?: number,) => { + return [ + `/catalog/proxy/myanimelist/${id}` + ] as const; + } + + +export const getGetMyAnimeListMangaDataByIdQueryOptions = >, TError = unknown>(id: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetMyAnimeListMangaDataByIdQueryKey(id); + + + + const queryFn: QueryFunction>> = ({ signal }) => getMyAnimeListMangaDataById(id, requestOptions, signal); + + + + + + return { queryKey, queryFn, enabled: !!(id), ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type GetMyAnimeListMangaDataByIdQueryResult = NonNullable>> +export type GetMyAnimeListMangaDataByIdQueryError = unknown + + +export function useGetMyAnimeListMangaDataById>, TError = unknown>( + id: number, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useGetMyAnimeListMangaDataById>, TError = unknown>( + id: number, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useGetMyAnimeListMangaDataById>, TError = unknown>( + id: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary Get manga data from MyAnimeList by ID + */ + +export function useGetMyAnimeListMangaDataById>, TError = unknown>( + id: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getGetMyAnimeListMangaDataByIdQueryOptions(id,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey ; + + return query; +} + + + + +/** + * Fetches manga information from AniList using the provided title. This endpoint serves as a proxy to search for manga data without directly exposing the AniList API. + * @summary Get manga data from AniList by title + */ +export const searchAniListMangaDataByTitle = ( + title: string, + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customInstance( + {url: `/catalog/proxy/anilist/${encodeURIComponent(String(title))}/search`, method: 'GET', signal + }, + options); + } + + + + +export const getSearchAniListMangaDataByTitleQueryKey = (title?: string,) => { + return [ + `/catalog/proxy/anilist/${title}/search` + ] as const; + } + + +export const getSearchAniListMangaDataByTitleQueryOptions = >, TError = unknown>(title: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getSearchAniListMangaDataByTitleQueryKey(title); + + + + const queryFn: QueryFunction>> = ({ signal }) => searchAniListMangaDataByTitle(title, requestOptions, signal); + + + + + + return { queryKey, queryFn, enabled: !!(title), ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type SearchAniListMangaDataByTitleQueryResult = NonNullable>> +export type SearchAniListMangaDataByTitleQueryError = unknown + + +export function useSearchAniListMangaDataByTitle>, TError = unknown>( + title: string, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useSearchAniListMangaDataByTitle>, TError = unknown>( + title: string, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useSearchAniListMangaDataByTitle>, TError = unknown>( + title: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary Get manga data from AniList by title + */ + +export function useSearchAniListMangaDataByTitle>, TError = unknown>( + title: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getSearchAniListMangaDataByTitleQueryOptions(title,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey ; + + return query; +} + + + + +/** + * Fetches manga information from AniList using the provided ID. This endpoint serves as a proxy to retrieve manga data without directly exposing the AniList API. + * @summary Get manga data from AniList by ID + */ +export const getAniListMangaDataById = ( + id: number, + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customInstance( + {url: `/catalog/proxy/anilist/${encodeURIComponent(String(id))}`, method: 'GET', signal + }, + options); + } + + + + +export const getGetAniListMangaDataByIdQueryKey = (id?: number,) => { + return [ + `/catalog/proxy/anilist/${id}` + ] as const; + } + + +export const getGetAniListMangaDataByIdQueryOptions = >, TError = unknown>(id: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetAniListMangaDataByIdQueryKey(id); + + + + const queryFn: QueryFunction>> = ({ signal }) => getAniListMangaDataById(id, requestOptions, signal); + + + + + + return { queryKey, queryFn, enabled: !!(id), ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type GetAniListMangaDataByIdQueryResult = NonNullable>> +export type GetAniListMangaDataByIdQueryError = unknown + + +export function useGetAniListMangaDataById>, TError = unknown>( + id: number, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useGetAniListMangaDataById>, TError = unknown>( + id: number, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useGetAniListMangaDataById>, TError = unknown>( + id: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary Get manga data from AniList by ID + */ + +export function useGetAniListMangaDataById>, TError = unknown>( + id: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getGetAniListMangaDataByIdQueryOptions(id,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey ; + + return query; +} + + + + diff --git a/src/features/admin/components/AniListInput.tsx b/src/features/admin/components/AniListInput.tsx new file mode 100644 index 0000000..9b5f553 --- /dev/null +++ b/src/features/admin/components/AniListInput.tsx @@ -0,0 +1,21 @@ +import { + useGetAniListMangaDataById, + useSearchAniListMangaDataByTitle, +} from "@/api/generated/catalog-proxy/catalog-proxy.ts" +import { CatalogSearchInput } from "./CatalogSearchInput" + +interface AniListSearchInputProps { + onChange: (id: number | undefined) => void + disabled?: boolean +} + +export const AniListInput = ({ onChange, disabled }: AniListSearchInputProps) => ( + +) diff --git a/src/features/admin/components/CatalogSearchInput.tsx b/src/features/admin/components/CatalogSearchInput.tsx new file mode 100644 index 0000000..8c1c92e --- /dev/null +++ b/src/features/admin/components/CatalogSearchInput.tsx @@ -0,0 +1,257 @@ +import { useState, useEffect, useRef } from "react" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { Card } from "@/components/ui/card" +import { Skeleton } from "@/components/ui/skeleton" +import { Search, RefreshCw } from "lucide-react" +import type { MangaProxyDataDTO } from "@/api/generated/api.schemas.ts" +import { useDebounce } from "use-debounce" +import { useQueryClient } from "@tanstack/react-query" +import { formatToTwoDigits } from "@/utils/dateFormatter.ts" +import type { UseQueryResult, DataTag, QueryKey } from "@tanstack/react-query" +import type { + DefaultResponseDTOListMangaProxyDataDTO, + DefaultResponseDTOMangaProxyDataDTO, +} from "@/api/generated/api.schemas.ts" + +type ByTitleResult = UseQueryResult & { + queryKey: DataTag +} + +type ByIdResult = UseQueryResult & { + queryKey: DataTag + refetch: () => void +} + +export interface CatalogSearchInputProps { + onChange: (id: number | undefined) => void + disabled?: boolean + /** + * Hook that searches by title. Should be enabled only when the text is non-empty and non-numeric. + * Signature mirrors the generated `useSearch*MangaDataByTitle` hooks. + */ + useSearchByTitle: ( + title: string, + options: { query: { enabled: boolean } } + ) => ByTitleResult + /** + * Hook that fetches a single entry by numeric ID. Should be called with `enabled: false` + * so the consumer controls when to fire (via the returned `refetch`). + * Signature mirrors the generated `useGet*MangaDataById` hooks. + */ + useGetById: ( + id: number, + options: { query: { enabled: boolean } } + ) => ByIdResult + idLabel?: string + placeholder?: string +} + +export const CatalogSearchInput = ({ + onChange, + disabled = false, + useSearchByTitle, + useGetById, + idLabel = "ID", + placeholder = "Search by title or enter ID...", +}: CatalogSearchInputProps) => { + const queryClient = useQueryClient() + + const [selectedManga, setSelectedManga] = useState(undefined) + const [searchText, setSearchText] = useState("") + const [debouncedSearchText] = useDebounce(searchText, 500) + const searchTriggered = useRef(false) + + const isNumericInput = /^\d+$/.test(debouncedSearchText.trim()) + + const showDropdown = !disabled && !isNumericInput && debouncedSearchText.trim().length > 0 + + const { + data: mangaDataById, + refetch: refetchMangaDataById, + isFetching: isFetchingMangaDataById, + queryKey: mangaDataByIdQueryKey, + } = useGetById(Number.parseInt(debouncedSearchText), { query: { enabled: false } }) + + const { + data: mangaDataByTitle, + isFetching: isFetchingMangaDataByTitle, + queryKey: mangaDataByTitleQueryKey, + } = useSearchByTitle(debouncedSearchText, { + query: { enabled: debouncedSearchText.trim().length > 0 && !isNumericInput }, + }) + + useEffect(() => { + onChange(selectedManga?.id) + }, [onChange, selectedManga]) + + useEffect(() => { + if (isNumericInput && mangaDataById?.data && searchTriggered.current) { + searchTriggered.current = false + setSelectedManga(mangaDataById.data) + setSearchText("") + } + }, [isNumericInput, mangaDataById?.data]) + + const handleSelectManga = (manga: MangaProxyDataDTO) => { + setSelectedManga(manga) + setSearchText("") + } + + const handleReset = () => { + setSelectedManga(undefined) + setSearchText("") + + queryClient.setQueryData(mangaDataByIdQueryKey, undefined) + queryClient.setQueryData(mangaDataByTitleQueryKey, undefined) + } + + // If a manga is selected, show the card + if (selectedManga) { + return ( + +
+ {selectedManga.title} +
+
+

{selectedManga.title}

+ {selectedManga.alternativeTitles && selectedManga.alternativeTitles.length > 0 && ( +

{selectedManga.alternativeTitles.join(", ")}

+ )} +
+ {/* The authors container gets 'truncate' and 'min-w-0' */} + + {selectedManga.authors.join(", ")} + + + {/* The date gets 'shrink-0' to ensure it never disappears */} + +  ยท {formatToTwoDigits(selectedManga.publishedAt)} + +
+
+
+ {idLabel}: {selectedManga.id} + +
+
+
+
+ ) + } + + return ( +
+
+ setSearchText(e.target.value)} + disabled={disabled} + className="pr-10" + /> + {!disabled && isNumericInput && searchText.trim() && ( + + )} +
+ + {/* Search Results Dropdown */} + {showDropdown && ( + + {isFetchingMangaDataByTitle ? ( +
+ {[1, 2, 3].map((i) => ( +
+ +
+ + + +
+
+ ))} +
) : + !isFetchingMangaDataByTitle && mangaDataByTitle?.data && mangaDataByTitle.data.length > 0 ? ( +
+ {mangaDataByTitle.data.map((manga, index) => ( +
+ {index > 0 && ( +
+ )} + +
+ ))} +
+ ) : ( +
+ No results found for "{searchText}" +
+ )} + + )} + +

+ Enter a number to search by {idLabel}, or type a title to search +

+
+ ) +} diff --git a/src/features/admin/components/MalInput.tsx b/src/features/admin/components/MalInput.tsx new file mode 100644 index 0000000..1599413 --- /dev/null +++ b/src/features/admin/components/MalInput.tsx @@ -0,0 +1,21 @@ +import { + useGetMyAnimeListMangaDataById, + useSearchMyAnimeListMangaDataByTitle, +} from "@/api/generated/catalog-proxy/catalog-proxy.ts" +import { CatalogSearchInput } from "./CatalogSearchInput" + +interface MalSearchInputProps { + onChange: (id: number | undefined) => void + disabled?: boolean +} + +export const MalInput = ({ onChange, disabled }: MalSearchInputProps) => ( + +) diff --git a/src/features/admin/components/MangaManualImportDialog.tsx b/src/features/admin/components/MangaManualImportDialog.tsx index b75ab00..e0be90e 100644 --- a/src/features/admin/components/MangaManualImportDialog.tsx +++ b/src/features/admin/components/MangaManualImportDialog.tsx @@ -13,8 +13,9 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog.tsx"; -import { Input } from "@/components/ui/input.tsx"; import { Progress } from "@/components/ui/progress.tsx"; +import {MalInput} from "@/features/admin/components/MalInput.tsx"; +import {AniListInput} from "@/features/admin/components/AniListInput.tsx"; interface MangaManualImportDialogProps { fileImportDialogOpen: boolean; @@ -146,24 +147,12 @@ export const MangaManualImportDialog = ({
- - setAniListId(e.target.value)} - className="mt-2" - disabled={isUploading} - /> + + setAniListId(value ? value.toString() : "")} disabled={isUploading}/>
- - setMalId(e.target.value)} - className="mt-2" - disabled={isUploading} - /> + + setMalId(value ? value.toString() : "")} disabled={isUploading} />
{!isUploading && (