feat: add import review functionality and improve error handling

This commit is contained in:
Rodrigo Verdiani 2025-10-25 10:36:43 -03:00
parent 112dfc7325
commit 73485fadda
11 changed files with 720 additions and 245 deletions

View File

@ -1,11 +1,17 @@
import axios, { AxiosRequestConfig } from "axios";
import axios, { AxiosRequestConfig, isAxiosError } from "axios";
import { User } from "@/contexts/auth-context";
import { toast } from "sonner";
export const Api = axios.create({
baseURL: "http://localhost:8080",
responseType: "json",
});
interface ErrorResponseDTO {
timestamp: string;
message: string;
}
Api.interceptors.request.use(async (config) => {
const jsonString = localStorage.getItem("userData");
if (jsonString) {
@ -16,6 +22,19 @@ Api.interceptors.request.use(async (config) => {
return config;
});
Api.interceptors.response.use(
(response) => response,
(error) => {
if (!isAxiosError(error)) {
console.log(error);
return;
}
const errorDTO = error.response?.data as ErrorResponseDTO;
toast.error(errorDTO.message);
},
);
export const customInstance = <T>(
config: AxiosRequestConfig,
options?: AxiosRequestConfig,

View File

@ -21,10 +21,22 @@ import type {
} from "@tanstack/react-query";
import { customInstance } from "./api";
export interface DefaultResponseDTOVoid {
timestamp?: string;
data?: unknown;
message?: string;
}
export interface ImportMangaDexRequestDTO {
id: string;
}
export interface DefaultResponseDTOImportMangaDexResponseDTO {
timestamp?: string;
data?: ImportMangaDexResponseDTO;
message?: string;
}
export interface ImportMangaDexResponseDTO {
id: number;
}
@ -56,6 +68,18 @@ export interface AuthenticationResponseDTO {
role: AuthenticationResponseDTORole;
}
export interface DefaultResponseDTOAuthenticationResponseDTO {
timestamp?: string;
data?: AuthenticationResponseDTO;
message?: string;
}
export interface DefaultResponseDTOPageMangaListDTO {
timestamp?: string;
data?: PageMangaListDTO;
message?: string;
}
export interface MangaListDTO {
id: number;
/** @minLength 1 */
@ -87,9 +111,9 @@ export interface PageMangaListDTO {
export interface PageableObject {
offset?: number;
paged?: boolean;
pageNumber?: number;
pageSize?: number;
paged?: boolean;
sort?: SortObject;
unpaged?: boolean;
}
@ -100,6 +124,12 @@ export interface SortObject {
unsorted?: boolean;
}
export interface DefaultResponseDTOListMangaChapterDTO {
timestamp?: string;
data?: MangaChapterDTO[];
message?: string;
}
export interface MangaChapterDTO {
id: number;
/** @minLength 1 */
@ -108,6 +138,12 @@ export interface MangaChapterDTO {
isRead: boolean;
}
export interface DefaultResponseDTOMangaDTO {
timestamp?: string;
data?: MangaDTO;
message?: string;
}
export interface MangaDTO {
id: number;
/** @minLength 1 */
@ -134,6 +170,12 @@ export interface MangaProviderDTO {
chaptersDownloaded: number;
}
export interface DefaultResponseDTOMangaChapterImagesDTO {
timestamp?: string;
data?: MangaChapterImagesDTO;
message?: string;
}
export interface MangaChapterImagesDTO {
id: number;
/** @minLength 1 */
@ -141,6 +183,30 @@ export interface MangaChapterImagesDTO {
chapterImageKeys: string[];
}
export interface DefaultResponseDTOListImportReviewDTO {
timestamp?: string;
data?: ImportReviewDTO[];
message?: string;
}
export interface ImportReviewDTO {
id: number;
/** @minLength 1 */
title: string;
/** @minLength 1 */
providerName: string;
externalUrl?: string;
/** @minLength 1 */
reason: string;
createdAt: string;
}
export interface DefaultResponseDTOListGenreDTO {
timestamp?: string;
data?: GenreDTO[];
message?: string;
}
export interface GenreDTO {
id: number;
/** @minLength 1 */
@ -167,6 +233,11 @@ export type ImportMultipleFilesBody = {
files: Blob[];
};
export type ResolveImportReviewParams = {
importReviewId: number;
malId: string;
};
export type GetMangasParams = {
searchQuery?: string;
genreIds?: number[];
@ -200,7 +271,7 @@ export const fetchMangaChapters = (
options?: SecondParameter<typeof customInstance>,
signal?: AbortSignal,
) => {
return customInstance<void>(
return customInstance<DefaultResponseDTOVoid>(
{
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/mangas/${encodeURIComponent(String(mangaProviderId))}/fetch-chapters`,
method: "POST",
@ -288,7 +359,7 @@ export const setUnfavorite = (
options?: SecondParameter<typeof customInstance>,
signal?: AbortSignal,
) => {
return customInstance<void>(
return customInstance<DefaultResponseDTOVoid>(
{
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/mangas/${encodeURIComponent(String(id))}/unfavorite`,
method: "POST",
@ -376,7 +447,7 @@ export const setFavorite = (
options?: SecondParameter<typeof customInstance>,
signal?: AbortSignal,
) => {
return customInstance<void>(
return customInstance<DefaultResponseDTOVoid>(
{
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/mangas/${encodeURIComponent(String(id))}/favorite`,
method: "POST",
@ -464,9 +535,9 @@ export const markAsRead = (
options?: SecondParameter<typeof customInstance>,
signal?: AbortSignal,
) => {
return customInstance<void>(
return customInstance<DefaultResponseDTOVoid>(
{
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/mangas/${encodeURIComponent(String(chapterId))}/mark-as-read`,
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/mangas/chapters/${encodeURIComponent(String(chapterId))}/mark-as-read`,
method: "POST",
signal,
},
@ -544,180 +615,7 @@ export const useMarkAsRead = <TError = unknown, TContext = unknown>(
};
/**
* @summary Update manga info
*/
export const updateMangaInfo = (
mangaId: number,
options?: SecondParameter<typeof customInstance>,
signal?: AbortSignal,
) => {
return customInstance<void>(
{
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/mangas/manga/${encodeURIComponent(String(mangaId))}/info`,
method: "POST",
signal,
},
options,
);
};
export const getUpdateMangaInfoMutationOptions = <
TError = unknown,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateMangaInfo>>,
TError,
{ mangaId: number },
TContext
>;
request?: SecondParameter<typeof customInstance>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateMangaInfo>>,
TError,
{ mangaId: number },
TContext
> => {
const mutationKey = ["updateMangaInfo"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateMangaInfo>>,
{ mangaId: number }
> = (props) => {
const { mangaId } = props ?? {};
return updateMangaInfo(mangaId, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateMangaInfoMutationResult = NonNullable<
Awaited<ReturnType<typeof updateMangaInfo>>
>;
export type UpdateMangaInfoMutationError = unknown;
/**
* @summary Update manga info
*/
export const useUpdateMangaInfo = <TError = unknown, TContext = unknown>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateMangaInfo>>,
TError,
{ mangaId: number },
TContext
>;
request?: SecondParameter<typeof customInstance>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof updateMangaInfo>>,
TError,
{ mangaId: number },
TContext
> => {
const mutationOptions = getUpdateMangaInfoMutationOptions(options);
return useMutation(mutationOptions, queryClient);
};
/**
* @summary Download all chapters for a manga provider
*/
export const downloadAllChapters = (
mangaProviderId: number,
options?: SecondParameter<typeof customInstance>,
signal?: AbortSignal,
) => {
return customInstance<void>(
{
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/mangas/chapter/${encodeURIComponent(String(mangaProviderId))}/download-all`,
method: "POST",
signal,
},
options,
);
};
export const getDownloadAllChaptersMutationOptions = <
TError = unknown,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof downloadAllChapters>>,
TError,
{ mangaProviderId: number },
TContext
>;
request?: SecondParameter<typeof customInstance>;
}): UseMutationOptions<
Awaited<ReturnType<typeof downloadAllChapters>>,
TError,
{ mangaProviderId: number },
TContext
> => {
const mutationKey = ["downloadAllChapters"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof downloadAllChapters>>,
{ mangaProviderId: number }
> = (props) => {
const { mangaProviderId } = props ?? {};
return downloadAllChapters(mangaProviderId, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type DownloadAllChaptersMutationResult = NonNullable<
Awaited<ReturnType<typeof downloadAllChapters>>
>;
export type DownloadAllChaptersMutationError = unknown;
/**
* @summary Download all chapters for a manga provider
*/
export const useDownloadAllChapters = <TError = unknown, TContext = unknown>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof downloadAllChapters>>,
TError,
{ mangaProviderId: number },
TContext
>;
request?: SecondParameter<typeof customInstance>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof downloadAllChapters>>,
TError,
{ mangaProviderId: number },
TContext
> => {
const mutationOptions = getDownloadAllChaptersMutationOptions(options);
return useMutation(mutationOptions, queryClient);
};
/**
* Fetch the chapter from the provider
* @summary Fetch chapter
*/
export const fetchChapter = (
@ -725,9 +623,9 @@ export const fetchChapter = (
options?: SecondParameter<typeof customInstance>,
signal?: AbortSignal,
) => {
return customInstance<void>(
return customInstance<DefaultResponseDTOVoid>(
{
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/mangas/chapter/${encodeURIComponent(String(chapterId))}/fetch`,
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/mangas/chapters/${encodeURIComponent(String(chapterId))}/fetch`,
method: "POST",
signal,
},
@ -805,6 +703,7 @@ export const useFetchChapter = <TError = unknown, TContext = unknown>(
};
/**
* Download a chapter as a compressed file by its ID.
* @summary Download chapter archive
*/
export const downloadChapterArchive = (
@ -815,7 +714,7 @@ export const downloadChapterArchive = (
) => {
return customInstance<Blob>(
{
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/mangas/chapter/${encodeURIComponent(String(chapterId))}/download-archive`,
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/mangas/chapters/${encodeURIComponent(String(chapterId))}/download`,
method: "POST",
params,
responseType: "blob",
@ -909,7 +808,7 @@ export const importMultipleFiles = (
formData.append(`files`, value),
);
return customInstance<string>(
return customInstance<DefaultResponseDTOVoid>(
{
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/manga/import/upload`,
method: "POST",
@ -990,6 +889,252 @@ export const useImportMultipleFiles = <TError = unknown, TContext = unknown>(
return useMutation(mutationOptions, queryClient);
};
/**
* Get list of pending import reviews.
* @summary Get list of pending import reviews
*/
export const getImportReviews = (
options?: SecondParameter<typeof customInstance>,
signal?: AbortSignal,
) => {
return customInstance<DefaultResponseDTOListImportReviewDTO>(
{
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/manga/import/review`,
method: "GET",
signal,
},
options,
);
};
export const getGetImportReviewsQueryKey = () => {
return [
`http://rov-lenovo.badger-pirarucu.ts.net:8080/manga/import/review`,
] as const;
};
export const getGetImportReviewsQueryOptions = <
TData = Awaited<ReturnType<typeof getImportReviews>>,
TError = unknown,
>(options?: {
query?: Partial<
UseQueryOptions<Awaited<ReturnType<typeof getImportReviews>>, TError, TData>
>;
request?: SecondParameter<typeof customInstance>;
}) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetImportReviewsQueryKey();
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getImportReviews>>
> = ({ signal }) => getImportReviews(requestOptions, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getImportReviews>>,
TError,
TData
> & { queryKey: DataTag<QueryKey, TData, TError> };
};
export type GetImportReviewsQueryResult = NonNullable<
Awaited<ReturnType<typeof getImportReviews>>
>;
export type GetImportReviewsQueryError = unknown;
export function useGetImportReviews<
TData = Awaited<ReturnType<typeof getImportReviews>>,
TError = unknown,
>(
options: {
query: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof getImportReviews>>,
TError,
TData
>
> &
Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof getImportReviews>>,
TError,
Awaited<ReturnType<typeof getImportReviews>>
>,
"initialData"
>;
request?: SecondParameter<typeof customInstance>;
},
queryClient?: QueryClient,
): DefinedUseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useGetImportReviews<
TData = Awaited<ReturnType<typeof getImportReviews>>,
TError = unknown,
>(
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof getImportReviews>>,
TError,
TData
>
> &
Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof getImportReviews>>,
TError,
Awaited<ReturnType<typeof getImportReviews>>
>,
"initialData"
>;
request?: SecondParameter<typeof customInstance>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useGetImportReviews<
TData = Awaited<ReturnType<typeof getImportReviews>>,
TError = unknown,
>(
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof getImportReviews>>,
TError,
TData
>
>;
request?: SecondParameter<typeof customInstance>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
/**
* @summary Get list of pending import reviews
*/
export function useGetImportReviews<
TData = Awaited<ReturnType<typeof getImportReviews>>,
TError = unknown,
>(
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof getImportReviews>>,
TError,
TData
>
>;
request?: SecondParameter<typeof customInstance>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
} {
const queryOptions = getGetImportReviewsQueryOptions(options);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
TData,
TError
> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* Resolve import review by ID.
* @summary Resolve import review
*/
export const resolveImportReview = (
params: ResolveImportReviewParams,
options?: SecondParameter<typeof customInstance>,
signal?: AbortSignal,
) => {
return customInstance<DefaultResponseDTOVoid>(
{
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/manga/import/review`,
method: "POST",
params,
signal,
},
options,
);
};
export const getResolveImportReviewMutationOptions = <
TError = unknown,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof resolveImportReview>>,
TError,
{ params: ResolveImportReviewParams },
TContext
>;
request?: SecondParameter<typeof customInstance>;
}): UseMutationOptions<
Awaited<ReturnType<typeof resolveImportReview>>,
TError,
{ params: ResolveImportReviewParams },
TContext
> => {
const mutationKey = ["resolveImportReview"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof resolveImportReview>>,
{ params: ResolveImportReviewParams }
> = (props) => {
const { params } = props ?? {};
return resolveImportReview(params, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type ResolveImportReviewMutationResult = NonNullable<
Awaited<ReturnType<typeof resolveImportReview>>
>;
export type ResolveImportReviewMutationError = unknown;
/**
* @summary Resolve import review
*/
export const useResolveImportReview = <TError = unknown, TContext = unknown>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof resolveImportReview>>,
TError,
{ params: ResolveImportReviewParams },
TContext
>;
request?: SecondParameter<typeof customInstance>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof resolveImportReview>>,
TError,
{ params: ResolveImportReviewParams },
TContext
> => {
const mutationOptions = getResolveImportReviewMutationOptions(options);
return useMutation(mutationOptions, queryClient);
};
/**
* Imports manga data from MangaDex into the local database.
* @summary Import manga from MangaDex
@ -999,7 +1144,7 @@ export const importFromMangaDex = (
options?: SecondParameter<typeof customInstance>,
signal?: AbortSignal,
) => {
return customInstance<ImportMangaDexResponseDTO>(
return customInstance<DefaultResponseDTOImportMangaDexResponseDTO>(
{
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/manga/import/manga-dex`,
method: "POST",
@ -1089,7 +1234,7 @@ export const registerUser = (
options?: SecondParameter<typeof customInstance>,
signal?: AbortSignal,
) => {
return customInstance<void>(
return customInstance<DefaultResponseDTOVoid>(
{
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/auth/register`,
method: "POST",
@ -1179,7 +1324,7 @@ export const authenticateUser = (
options?: SecondParameter<typeof customInstance>,
signal?: AbortSignal,
) => {
return customInstance<AuthenticationResponseDTO>(
return customInstance<DefaultResponseDTOAuthenticationResponseDTO>(
{
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/auth/login`,
method: "POST",
@ -1269,7 +1414,7 @@ export const getMangas = (
options?: SecondParameter<typeof customInstance>,
signal?: AbortSignal,
) => {
return customInstance<PageMangaListDTO>(
return customInstance<DefaultResponseDTOPageMangaListDTO>(
{
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/mangas`,
method: "GET",
@ -1420,7 +1565,7 @@ export const getMangaChapters = (
options?: SecondParameter<typeof customInstance>,
signal?: AbortSignal,
) => {
return customInstance<MangaChapterDTO[]>(
return customInstance<DefaultResponseDTOListMangaChapterDTO>(
{
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/mangas/${encodeURIComponent(String(mangaProviderId))}/chapters`,
method: "GET",
@ -1597,7 +1742,7 @@ export const getManga = (
options?: SecondParameter<typeof customInstance>,
signal?: AbortSignal,
) => {
return customInstance<MangaDTO>(
return customInstance<DefaultResponseDTOMangaDTO>(
{
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/mangas/${encodeURIComponent(String(mangaId))}`,
method: "GET",
@ -1741,17 +1886,17 @@ export function useGetManga<
}
/**
* Retrieve a list of manga chapters for a specific manga/provider combination.
* @summary Get the available chapters for a specific manga/provider combination
* Retrieve a list of manga chapter images for a specific manga/provider combination.
* @summary Get the images for a specific manga/provider combination
*/
export const getMangaChapterImages = (
chapterId: number,
options?: SecondParameter<typeof customInstance>,
signal?: AbortSignal,
) => {
return customInstance<MangaChapterImagesDTO>(
return customInstance<DefaultResponseDTOMangaChapterImagesDTO>(
{
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/mangas/${encodeURIComponent(String(chapterId))}/images`,
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/mangas/chapters/${encodeURIComponent(String(chapterId))}/images`,
method: "GET",
signal,
},
@ -1761,7 +1906,7 @@ export const getMangaChapterImages = (
export const getGetMangaChapterImagesQueryKey = (chapterId?: number) => {
return [
`http://rov-lenovo.badger-pirarucu.ts.net:8080/mangas/${chapterId}/images`,
`http://rov-lenovo.badger-pirarucu.ts.net:8080/mangas/chapters/${chapterId}/images`,
] as const;
};
@ -1881,7 +2026,7 @@ export function useGetMangaChapterImages<
queryKey: DataTag<QueryKey, TData, TError>;
};
/**
* @summary Get the available chapters for a specific manga/provider combination
* @summary Get the images for a specific manga/provider combination
*/
export function useGetMangaChapterImages<
@ -1923,7 +2068,7 @@ export const getGenres = (
options?: SecondParameter<typeof customInstance>,
signal?: AbortSignal,
) => {
return customInstance<GenreDTO[]>(
return customInstance<DefaultResponseDTOListGenreDTO>(
{
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/genres`,
method: "GET",
@ -2053,3 +2198,89 @@ export function useGetGenres<
return query;
}
/**
* Delete pending import review by ID.
* @summary Delete pending import review
*/
export const deleteImportReview = (
id: number,
options?: SecondParameter<typeof customInstance>,
) => {
return customInstance<DefaultResponseDTOVoid>(
{
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/manga/import/review/${encodeURIComponent(String(id))}`,
method: "DELETE",
},
options,
);
};
export const getDeleteImportReviewMutationOptions = <
TError = unknown,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteImportReview>>,
TError,
{ id: number },
TContext
>;
request?: SecondParameter<typeof customInstance>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteImportReview>>,
TError,
{ id: number },
TContext
> => {
const mutationKey = ["deleteImportReview"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof deleteImportReview>>,
{ id: number }
> = (props) => {
const { id } = props ?? {};
return deleteImportReview(id, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteImportReviewMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteImportReview>>
>;
export type DeleteImportReviewMutationError = unknown;
/**
* @summary Delete pending import review
*/
export const useDeleteImportReview = <TError = unknown, TContext = unknown>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteImportReview>>,
TError,
{ id: number },
TContext
>;
request?: SecondParameter<typeof customInstance>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof deleteImportReview>>,
TError,
{ id: number },
TContext
> => {
const mutationOptions = getDeleteImportReviewMutationOptions(options);
return useMutation(mutationOptions, queryClient);
};

View File

@ -0,0 +1,70 @@
"use client";
import { useAuth } from "@/contexts/auth-context";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { FailedImportCard } from "@/components/failed-import-card";
import { Card } from "@/components/ui/card";
import { AlertCircle } from "lucide-react";
import { useGetImportReviews } from "@/api/mangamochi";
export default function ImportReviewPage() {
const { user, isAuthenticated } = useAuth();
const router = useRouter();
const { data: failedImportsData, queryKey } = useGetImportReviews();
useEffect(() => {
if (!user) {
return;
}
if (!isAuthenticated) {
router.push("/login");
}
}, [isAuthenticated, router, user]);
if (!user) {
return null;
}
return (
<main className="min-h-screen bg-background">
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground">Import Review</h1>
<p className="mt-2 text-muted-foreground">
Review and resolve manga imports by manually matching them with
MyAnimeList entries.
</p>
</div>
{!failedImportsData?.data || failedImportsData.data.length === 0 ? (
<Card className="p-8 text-center">
<AlertCircle className="mx-auto h-12 w-12 text-muted-foreground" />
<h2 className="mt-4 text-lg font-semibold text-foreground">
No Imports to Review
</h2>
<p className="mt-2 text-muted-foreground">
All your imports have been processed successfully!
</p>
</Card>
) : (
<div className="space-y-4">
<div className="text-sm text-muted-foreground">
{failedImportsData.data.length} import
{failedImportsData.data.length !== 1 ? "s" : ""} to review
</div>
{failedImportsData.data.map((failedImport) => (
<FailedImportCard
key={failedImport.id}
failedImport={failedImport}
queryKey={queryKey}
/>
))}
</div>
)}
</div>
</main>
);
}

View File

@ -25,12 +25,12 @@ export default function ChapterReaderPage() {
return;
}
if (currentPage === data.chapterImageKeys.length) {
if (currentPage === data.data?.chapterImageKeys.length) {
mutate({ chapterId: chapterNumber });
}
}, [data, mutate, currentPage]);
if (!data) {
if (!data?.data) {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="text-center">
@ -46,7 +46,11 @@ export default function ChapterReaderPage() {
}
const goToNextPage = () => {
if (currentPage < data?.chapterImageKeys.length) {
if (!data?.data) {
return;
}
if (currentPage < data.data.chapterImageKeys.length) {
setCurrentPage(currentPage + 1);
window.scrollTo({ top: 0, behavior: "smooth" });
}
@ -86,7 +90,7 @@ export default function ChapterReaderPage() {
</Button>
<div className="hidden sm:block">
<h1 className="text-sm font-semibold text-foreground">
{data.mangaTitle}
{data.data.mangaTitle}
</h1>
<p className="text-xs text-muted-foreground">
Chapter {chapterNumber}
@ -95,7 +99,7 @@ export default function ChapterReaderPage() {
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground">
Page {currentPage} / {data.chapterImageKeys.length}
Page {currentPage} / {data.data.chapterImageKeys.length}
</span>
<ThemeToggle />
</div>
@ -108,7 +112,7 @@ export default function ChapterReaderPage() {
{/* Mobile title */}
<div className="mb-4 sm:hidden">
<h1 className="text-lg font-semibold text-foreground">
{data.mangaTitle}
{data.data.mangaTitle}
</h1>
<p className="text-sm text-muted-foreground">
Chapter {chapterNumber}
@ -120,7 +124,8 @@ export default function ChapterReaderPage() {
<Image
src={
"http://omv.badger-pirarucu.ts.net:9000/mangamochi/" +
data.chapterImageKeys[currentPage - 1] || "/placeholder.svg"
data.data.chapterImageKeys[currentPage - 1] ||
"/placeholder.svg"
}
alt={`Page ${currentPage}`}
width={1000}
@ -144,11 +149,11 @@ export default function ChapterReaderPage() {
Previous Page
</Button>
<span className="text-sm font-medium text-foreground">
{currentPage} / {data.chapterImageKeys.length}
{currentPage} / {data.data.chapterImageKeys.length}
</span>
<Button
onClick={goToNextPage}
disabled={currentPage === data.chapterImageKeys.length}
disabled={currentPage === data.data.chapterImageKeys.length}
variant="outline"
className="gap-2 bg-transparent"
>

View File

@ -68,7 +68,7 @@ export default function MangaDetailPage() {
});
};
const author = mangaData.authors.join(", ");
const author = mangaData.data?.authors.join(", ");
const formatter = new Intl.DateTimeFormat("en-US", {
month: "2-digit",
@ -76,11 +76,11 @@ export default function MangaDetailPage() {
year: "numeric",
});
const publishedTo = mangaData.publishedTo
? formatter.format(new Date(mangaData.publishedTo))
const publishedTo = mangaData.data?.publishedTo
? formatter.format(new Date(mangaData.data.publishedTo))
: null;
const publishedFrom = mangaData.publishedFrom
? formatter.format(new Date(mangaData.publishedFrom))
const publishedFrom = mangaData.data?.publishedFrom
? formatter.format(new Date(mangaData.data.publishedFrom))
: null;
const dateRange = publishedTo
@ -118,12 +118,12 @@ export default function MangaDetailPage() {
<div className="relative aspect-[2/3] overflow-hidden rounded-lg border border-border bg-muted lg:sticky lg:top-8 lg:h-fit">
<Image
src={
(mangaData.coverImageKey &&
(mangaData.data?.coverImageKey &&
"http://omv.badger-pirarucu.ts.net:9000/mangamochi/" +
mangaData.coverImageKey) ||
mangaData.data?.coverImageKey) ||
"/placeholder.svg"
}
alt={mangaData.title}
alt={mangaData.data?.title ?? ""}
fill
className="object-cover"
/>
@ -134,26 +134,26 @@ export default function MangaDetailPage() {
<div>
<div className="mb-3 flex items-start justify-between gap-4">
<h1 className="text-balance text-4xl font-bold tracking-tight text-foreground">
{mangaData.title}
{mangaData.data?.title}
</h1>
<Badge
variant="secondary"
className="border border-border bg-card text-foreground"
>
{mangaData.status}
{mangaData.data?.status}
</Badge>
</div>
<p className="text-lg text-muted-foreground">{author}</p>
</div>
{mangaData.alternativeTitles &&
mangaData.alternativeTitles.length > 0 && (
{mangaData.data?.alternativeTitles &&
mangaData.data?.alternativeTitles.length > 0 && (
<div>
<h3 className="mb-2 text-sm font-medium text-muted-foreground">
Alternative Titles
</h3>
<div className="flex flex-wrap gap-2">
{mangaData.alternativeTitles.map((title, index) => (
{mangaData.data?.alternativeTitles.map((title, index) => (
<span
key={index}
className="rounded-md bg-muted px-3 py-1 text-sm text-foreground"
@ -166,7 +166,7 @@ export default function MangaDetailPage() {
)}
<p className="text-pretty text-justify leading-relaxed text-foreground">
{mangaData.synopsis}
{mangaData.data?.synopsis}
</p>
<div className="grid gap-4 sm:grid-cols-2">
@ -175,8 +175,8 @@ export default function MangaDetailPage() {
<div>
<p className="text-sm text-muted-foreground">Rating</p>
<p className="text-lg font-semibold text-foreground">
{mangaData.score && mangaData.score > 0
? mangaData.score
{mangaData.data?.score && mangaData.data?.score > 0
? mangaData.data?.score
: "-"}
</p>
</div>
@ -187,8 +187,9 @@ export default function MangaDetailPage() {
<div>
<p className="text-sm text-muted-foreground">Chapters</p>
<p className="text-lg font-semibold text-foreground">
{mangaData.chapterCount && mangaData.chapterCount > 0
? mangaData.chapterCount
{mangaData.data?.chapterCount &&
mangaData.data?.chapterCount > 0
? mangaData.data?.chapterCount
: "-"}
</p>
</div>
@ -209,14 +210,14 @@ export default function MangaDetailPage() {
<div>
<p className="text-sm text-muted-foreground">Providers</p>
<p className="text-lg font-semibold text-foreground">
{mangaData.providerCount}
{mangaData.data?.providerCount}
</p>
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
{mangaData.genres.map((genre) => (
{mangaData.data?.genres.map((genre) => (
<Badge
key={genre}
variant="outline"
@ -235,7 +236,7 @@ export default function MangaDetailPage() {
</h2>
<div className="space-y-4">
{mangaData?.providers.map((provider) => (
{mangaData.data?.providers.map((provider) => (
<Card key={provider.id} className="border-border bg-card">
<Collapsible
open={openProviders.has(provider.id)}

View File

@ -36,7 +36,7 @@ export default function HomePage() {
score: minRating,
});
const totalPages = mangas?.totalPages;
const totalPages = mangas?.data?.totalPages;
return (
<div className="flex min-h-screen bg-background">
@ -67,7 +67,7 @@ export default function HomePage() {
MangaMochi
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{mangas?.totalElements} titles available
{mangas?.data?.totalElements} titles available
</p>
</div>
</div>
@ -97,17 +97,17 @@ export default function HomePage() {
</header>
<main className="px-8 py-8">
{mangas?.content && mangas.content.length > 0 ? (
{mangas?.data?.content && mangas.data.content.length > 0 ? (
<>
{mangas?.totalElements && (
{mangas?.data?.totalElements && (
<div className="mb-6 flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Showing {(currentPage - 1) * ITEMS_PER_PAGE + 1} to{" "}
{Math.min(
currentPage * ITEMS_PER_PAGE,
mangas.totalElements,
mangas.data.totalElements,
)}{" "}
of {mangas.totalElements}
of {mangas.data.totalElements}
</p>
<SortOptions
currentSort={sortOption}
@ -115,7 +115,7 @@ export default function HomePage() {
/>
</div>
)}
<MangaGrid manga={mangas?.content} queryKey={queryKey} />
<MangaGrid manga={mangas?.data?.content} queryKey={queryKey} />
{totalPages && totalPages > 1 && (
<div className="mt-12">

View File

@ -0,0 +1,134 @@
"use client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card } from "@/components/ui/card";
import { useState } from "react";
import { ExternalLink, Trash2 } from "lucide-react";
import {
ImportReviewDTO,
useDeleteImportReview,
useResolveImportReview,
} from "@/api/mangamochi";
import { toast } from "sonner";
import { useQueryClient } from "@tanstack/react-query";
interface FailedImportCardProps {
failedImport: ImportReviewDTO;
queryKey: any;
}
export function FailedImportCard({
failedImport,
queryKey,
}: FailedImportCardProps) {
const queryClient = useQueryClient();
const [malId, setMalId] = useState("");
const {
mutate: mutateDeleteImportReview,
isPending: isPendingDeleteImportReview,
} = useDeleteImportReview({
mutation: {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey });
toast.success("Import review removed successfully");
},
},
});
const {
mutate: mutateResolveImportReview,
isPending: isPendingResolveImportReview,
} = useResolveImportReview({
mutation: {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey });
toast.success("Import review resolved successfully");
},
},
});
const handleResolve = () => {
if (!malId.trim()) {
alert("Please enter a MyAnimeList ID");
return;
}
mutateResolveImportReview({
params: { importReviewId: failedImport.id, malId },
});
};
const handleRemove = () => {
mutateDeleteImportReview({ id: failedImport.id });
};
const importDate = new Date(failedImport.createdAt).toLocaleDateString();
return (
<Card className="p-4">
<div className="space-y-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="font-semibold text-foreground">
{failedImport.title}
</h3>
<p className="text-sm text-muted-foreground">
Provider:{" "}
<span className="capitalize">{failedImport.providerName}</span> {" "}
{importDate}
</p>
</div>
{failedImport.externalUrl && (
<a
href={failedImport.externalUrl}
target="_blank"
rel="noopener noreferrer"
className="ml-2 text-primary hover:underline"
>
<ExternalLink className="h-4 w-4" />
</a>
)}
</div>
<div className="rounded-md bg-destructive/10 p-3">
<p className="text-sm text-destructive">{failedImport.reason}</p>
</div>
<div className="space-y-3">
<div>
<label className="text-sm font-medium">MyAnimeList Manga ID</label>
<Input
placeholder="Enter MAL ID to match manually"
value={malId}
onChange={(e) => setMalId(e.target.value)}
className="mt-2"
type="number"
/>
</div>
<div className="flex gap-2">
<Button
onClick={handleResolve}
disabled={isPendingResolveImportReview || !malId.trim()}
className="flex-1"
>
{isPendingResolveImportReview ? "Resolving..." : "Resolve Import"}
</Button>
<Button
onClick={handleRemove}
variant="outline"
size="icon"
className="text-destructive hover:text-destructive bg-transparent"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</Card>
);
}

View File

@ -137,7 +137,7 @@ export function FilterSidebar({
<div className="flex flex-wrap gap-2">
{isPendingGenres && <Skeleton className="h-[240px] w-full" />}
{!isPendingGenres &&
genresData?.map((genre) => {
genresData?.data?.map((genre) => {
const isSelected = selectedGenres.includes(genre.id);
return (
<Badge

View File

@ -8,12 +8,14 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Download, FileUp } from "lucide-react";
import { AlertCircle, Download, FileUp } from "lucide-react";
import { useAuth } from "@/contexts/auth-context";
import { MangaDexImportDialog } from "@/components/manga-dex-import-dialog";
import { MangaManualImportDialog } from "@/components/manga-manual-import-dialog";
import Link from "next/link";
export function ImportDropdown() {
const { isAuthenticated } = useAuth();
@ -43,6 +45,17 @@ export function ImportDropdown() {
<FileUp className="mr-2 h-4 w-4" />
Import from File
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
href="/import-review"
className="cursor-pointer gap-2 text-amber-600 dark:text-amber-500"
>
<AlertCircle className="h-4 w-4" />
<span>Import Review</span>
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@ -74,7 +74,7 @@ export const MangaChapter: FC<MangaChapterProps> = ({
</div>
) : data ? (
<div className="mt-4 space-y-2">
{data.map((chapter) => {
{data.data?.map((chapter) => {
return (
<div
key={chapter.id}

View File

@ -48,7 +48,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
router.push("/login");
toast.success("Registration successful! You can now login.");
},
onError: () => toast.error("Registration failed. Please try again."),
},
});
@ -56,12 +55,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
useAuthenticateUser({
mutation: {
onSuccess: (response) => {
if (!response.data) {
return;
}
const userData: User = {
id: response.id,
email: response.email,
name: response.name,
token: response.token,
role: response.role,
id: response.data.id,
email: response.data.email,
name: response.data.name,
token: response.data.token,
role: response.data.role,
};
setUser(userData);
@ -70,7 +73,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
router.push("/");
},
onError: () => toast.error("Login failed. Please try again."),
},
});