feat: implement manga import functionality and enhance filter options
This commit is contained in:
parent
650891722e
commit
112dfc7325
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
@ -21,6 +21,14 @@ import type {
|
|||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
|
|
||||||
import { customInstance } from "./api";
|
import { customInstance } from "./api";
|
||||||
|
export interface ImportMangaDexRequestDTO {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportMangaDexResponseDTO {
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RegistrationRequestDTO {
|
export interface RegistrationRequestDTO {
|
||||||
name?: string;
|
name?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
@ -79,9 +87,9 @@ export interface PageMangaListDTO {
|
|||||||
|
|
||||||
export interface PageableObject {
|
export interface PageableObject {
|
||||||
offset?: number;
|
offset?: number;
|
||||||
paged?: boolean;
|
|
||||||
pageNumber?: number;
|
pageNumber?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
|
paged?: boolean;
|
||||||
sort?: SortObject;
|
sort?: SortObject;
|
||||||
unpaged?: boolean;
|
unpaged?: boolean;
|
||||||
}
|
}
|
||||||
@ -115,6 +123,7 @@ export interface MangaDTO {
|
|||||||
authors: string[];
|
authors: string[];
|
||||||
score: number;
|
score: number;
|
||||||
providers: MangaProviderDTO[];
|
providers: MangaProviderDTO[];
|
||||||
|
chapterCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MangaProviderDTO {
|
export interface MangaProviderDTO {
|
||||||
@ -151,11 +160,19 @@ export const DownloadChapterArchiveArchiveFileType = {
|
|||||||
CBR: "CBR",
|
CBR: "CBR",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export type ImportMultipleFilesBody = {
|
||||||
|
/** @minLength 1 */
|
||||||
|
malId: string;
|
||||||
|
/** List of files to upload */
|
||||||
|
files: Blob[];
|
||||||
|
};
|
||||||
|
|
||||||
export type GetMangasParams = {
|
export type GetMangasParams = {
|
||||||
searchQuery?: string;
|
searchQuery?: string;
|
||||||
genreIds?: number[];
|
genreIds?: number[];
|
||||||
statuses?: string[];
|
statuses?: string[];
|
||||||
userFavorites?: boolean;
|
userFavorites?: boolean;
|
||||||
|
score?: number;
|
||||||
/**
|
/**
|
||||||
* Zero-based page index (0..N)
|
* Zero-based page index (0..N)
|
||||||
* @minimum 0
|
* @minimum 0
|
||||||
@ -185,7 +202,7 @@ export const fetchMangaChapters = (
|
|||||||
) => {
|
) => {
|
||||||
return customInstance<void>(
|
return customInstance<void>(
|
||||||
{
|
{
|
||||||
url: `http://192.168.1.142:8080/mangas/${encodeURIComponent(String(mangaProviderId))}/fetch-chapters`,
|
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/mangas/${encodeURIComponent(String(mangaProviderId))}/fetch-chapters`,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
signal,
|
signal,
|
||||||
},
|
},
|
||||||
@ -273,7 +290,7 @@ export const setUnfavorite = (
|
|||||||
) => {
|
) => {
|
||||||
return customInstance<void>(
|
return customInstance<void>(
|
||||||
{
|
{
|
||||||
url: `http://192.168.1.142:8080/mangas/${encodeURIComponent(String(id))}/unfavorite`,
|
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/mangas/${encodeURIComponent(String(id))}/unfavorite`,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
signal,
|
signal,
|
||||||
},
|
},
|
||||||
@ -361,7 +378,7 @@ export const setFavorite = (
|
|||||||
) => {
|
) => {
|
||||||
return customInstance<void>(
|
return customInstance<void>(
|
||||||
{
|
{
|
||||||
url: `http://192.168.1.142:8080/mangas/${encodeURIComponent(String(id))}/favorite`,
|
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/mangas/${encodeURIComponent(String(id))}/favorite`,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
signal,
|
signal,
|
||||||
},
|
},
|
||||||
@ -449,7 +466,7 @@ export const markAsRead = (
|
|||||||
) => {
|
) => {
|
||||||
return customInstance<void>(
|
return customInstance<void>(
|
||||||
{
|
{
|
||||||
url: `http://192.168.1.142:8080/mangas/${encodeURIComponent(String(chapterId))}/mark-as-read`,
|
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/mangas/${encodeURIComponent(String(chapterId))}/mark-as-read`,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
signal,
|
signal,
|
||||||
},
|
},
|
||||||
@ -536,7 +553,7 @@ export const updateMangaInfo = (
|
|||||||
) => {
|
) => {
|
||||||
return customInstance<void>(
|
return customInstance<void>(
|
||||||
{
|
{
|
||||||
url: `http://192.168.1.142:8080/mangas/manga/${encodeURIComponent(String(mangaId))}/info`,
|
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/mangas/manga/${encodeURIComponent(String(mangaId))}/info`,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
signal,
|
signal,
|
||||||
},
|
},
|
||||||
@ -623,7 +640,7 @@ export const downloadAllChapters = (
|
|||||||
) => {
|
) => {
|
||||||
return customInstance<void>(
|
return customInstance<void>(
|
||||||
{
|
{
|
||||||
url: `http://192.168.1.142:8080/mangas/chapter/${encodeURIComponent(String(mangaProviderId))}/download-all`,
|
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/mangas/chapter/${encodeURIComponent(String(mangaProviderId))}/download-all`,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
signal,
|
signal,
|
||||||
},
|
},
|
||||||
@ -710,7 +727,7 @@ export const fetchChapter = (
|
|||||||
) => {
|
) => {
|
||||||
return customInstance<void>(
|
return customInstance<void>(
|
||||||
{
|
{
|
||||||
url: `http://192.168.1.142:8080/mangas/chapter/${encodeURIComponent(String(chapterId))}/fetch`,
|
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/mangas/chapter/${encodeURIComponent(String(chapterId))}/fetch`,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
signal,
|
signal,
|
||||||
},
|
},
|
||||||
@ -798,7 +815,7 @@ export const downloadChapterArchive = (
|
|||||||
) => {
|
) => {
|
||||||
return customInstance<Blob>(
|
return customInstance<Blob>(
|
||||||
{
|
{
|
||||||
url: `http://192.168.1.142:8080/mangas/chapter/${encodeURIComponent(String(chapterId))}/download-archive`,
|
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/mangas/chapter/${encodeURIComponent(String(chapterId))}/download-archive`,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
params,
|
params,
|
||||||
responseType: "blob",
|
responseType: "blob",
|
||||||
@ -877,6 +894,192 @@ export const useDownloadChapterArchive = <TError = unknown, TContext = unknown>(
|
|||||||
return useMutation(mutationOptions, queryClient);
|
return useMutation(mutationOptions, queryClient);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accepts multiple files via multipart/form-data and processes them.
|
||||||
|
* @summary Upload multiple files
|
||||||
|
*/
|
||||||
|
export const importMultipleFiles = (
|
||||||
|
importMultipleFilesBody: ImportMultipleFilesBody,
|
||||||
|
options?: SecondParameter<typeof customInstance>,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append(`malId`, importMultipleFilesBody.malId);
|
||||||
|
importMultipleFilesBody.files.forEach((value) =>
|
||||||
|
formData.append(`files`, value),
|
||||||
|
);
|
||||||
|
|
||||||
|
return customInstance<string>(
|
||||||
|
{
|
||||||
|
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/manga/import/upload`,
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
data: formData,
|
||||||
|
signal,
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getImportMultipleFilesMutationOptions = <
|
||||||
|
TError = unknown,
|
||||||
|
TContext = unknown,
|
||||||
|
>(options?: {
|
||||||
|
mutation?: UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof importMultipleFiles>>,
|
||||||
|
TError,
|
||||||
|
{ data: ImportMultipleFilesBody },
|
||||||
|
TContext
|
||||||
|
>;
|
||||||
|
request?: SecondParameter<typeof customInstance>;
|
||||||
|
}): UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof importMultipleFiles>>,
|
||||||
|
TError,
|
||||||
|
{ data: ImportMultipleFilesBody },
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
const mutationKey = ["importMultipleFiles"];
|
||||||
|
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 importMultipleFiles>>,
|
||||||
|
{ data: ImportMultipleFilesBody }
|
||||||
|
> = (props) => {
|
||||||
|
const { data } = props ?? {};
|
||||||
|
|
||||||
|
return importMultipleFiles(data, requestOptions);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ImportMultipleFilesMutationResult = NonNullable<
|
||||||
|
Awaited<ReturnType<typeof importMultipleFiles>>
|
||||||
|
>;
|
||||||
|
export type ImportMultipleFilesMutationBody = ImportMultipleFilesBody;
|
||||||
|
export type ImportMultipleFilesMutationError = unknown;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Upload multiple files
|
||||||
|
*/
|
||||||
|
export const useImportMultipleFiles = <TError = unknown, TContext = unknown>(
|
||||||
|
options?: {
|
||||||
|
mutation?: UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof importMultipleFiles>>,
|
||||||
|
TError,
|
||||||
|
{ data: ImportMultipleFilesBody },
|
||||||
|
TContext
|
||||||
|
>;
|
||||||
|
request?: SecondParameter<typeof customInstance>;
|
||||||
|
},
|
||||||
|
queryClient?: QueryClient,
|
||||||
|
): UseMutationResult<
|
||||||
|
Awaited<ReturnType<typeof importMultipleFiles>>,
|
||||||
|
TError,
|
||||||
|
{ data: ImportMultipleFilesBody },
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
const mutationOptions = getImportMultipleFilesMutationOptions(options);
|
||||||
|
|
||||||
|
return useMutation(mutationOptions, queryClient);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imports manga data from MangaDex into the local database.
|
||||||
|
* @summary Import manga from MangaDex
|
||||||
|
*/
|
||||||
|
export const importFromMangaDex = (
|
||||||
|
importMangaDexRequestDTO: ImportMangaDexRequestDTO,
|
||||||
|
options?: SecondParameter<typeof customInstance>,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) => {
|
||||||
|
return customInstance<ImportMangaDexResponseDTO>(
|
||||||
|
{
|
||||||
|
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/manga/import/manga-dex`,
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
data: importMangaDexRequestDTO,
|
||||||
|
signal,
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getImportFromMangaDexMutationOptions = <
|
||||||
|
TError = unknown,
|
||||||
|
TContext = unknown,
|
||||||
|
>(options?: {
|
||||||
|
mutation?: UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof importFromMangaDex>>,
|
||||||
|
TError,
|
||||||
|
{ data: ImportMangaDexRequestDTO },
|
||||||
|
TContext
|
||||||
|
>;
|
||||||
|
request?: SecondParameter<typeof customInstance>;
|
||||||
|
}): UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof importFromMangaDex>>,
|
||||||
|
TError,
|
||||||
|
{ data: ImportMangaDexRequestDTO },
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
const mutationKey = ["importFromMangaDex"];
|
||||||
|
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 importFromMangaDex>>,
|
||||||
|
{ data: ImportMangaDexRequestDTO }
|
||||||
|
> = (props) => {
|
||||||
|
const { data } = props ?? {};
|
||||||
|
|
||||||
|
return importFromMangaDex(data, requestOptions);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ImportFromMangaDexMutationResult = NonNullable<
|
||||||
|
Awaited<ReturnType<typeof importFromMangaDex>>
|
||||||
|
>;
|
||||||
|
export type ImportFromMangaDexMutationBody = ImportMangaDexRequestDTO;
|
||||||
|
export type ImportFromMangaDexMutationError = unknown;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Import manga from MangaDex
|
||||||
|
*/
|
||||||
|
export const useImportFromMangaDex = <TError = unknown, TContext = unknown>(
|
||||||
|
options?: {
|
||||||
|
mutation?: UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof importFromMangaDex>>,
|
||||||
|
TError,
|
||||||
|
{ data: ImportMangaDexRequestDTO },
|
||||||
|
TContext
|
||||||
|
>;
|
||||||
|
request?: SecondParameter<typeof customInstance>;
|
||||||
|
},
|
||||||
|
queryClient?: QueryClient,
|
||||||
|
): UseMutationResult<
|
||||||
|
Awaited<ReturnType<typeof importFromMangaDex>>,
|
||||||
|
TError,
|
||||||
|
{ data: ImportMangaDexRequestDTO },
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
const mutationOptions = getImportFromMangaDexMutationOptions(options);
|
||||||
|
|
||||||
|
return useMutation(mutationOptions, queryClient);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a new user.
|
* Register a new user.
|
||||||
* @summary Register user
|
* @summary Register user
|
||||||
@ -888,7 +1091,7 @@ export const registerUser = (
|
|||||||
) => {
|
) => {
|
||||||
return customInstance<void>(
|
return customInstance<void>(
|
||||||
{
|
{
|
||||||
url: `http://192.168.1.142:8080/auth/register`,
|
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/auth/register`,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
data: registrationRequestDTO,
|
data: registrationRequestDTO,
|
||||||
@ -978,7 +1181,7 @@ export const authenticateUser = (
|
|||||||
) => {
|
) => {
|
||||||
return customInstance<AuthenticationResponseDTO>(
|
return customInstance<AuthenticationResponseDTO>(
|
||||||
{
|
{
|
||||||
url: `http://192.168.1.142:8080/auth/login`,
|
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/auth/login`,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
data: authenticationRequestDTO,
|
data: authenticationRequestDTO,
|
||||||
@ -1067,14 +1270,19 @@ export const getMangas = (
|
|||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
) => {
|
) => {
|
||||||
return customInstance<PageMangaListDTO>(
|
return customInstance<PageMangaListDTO>(
|
||||||
{ url: `http://192.168.1.142:8080/mangas`, method: "GET", params, signal },
|
{
|
||||||
|
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/mangas`,
|
||||||
|
method: "GET",
|
||||||
|
params,
|
||||||
|
signal,
|
||||||
|
},
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getGetMangasQueryKey = (params?: GetMangasParams) => {
|
export const getGetMangasQueryKey = (params?: GetMangasParams) => {
|
||||||
return [
|
return [
|
||||||
`http://192.168.1.142:8080/mangas`,
|
`http://rov-lenovo.badger-pirarucu.ts.net:8080/mangas`,
|
||||||
...(params ? [params] : []),
|
...(params ? [params] : []),
|
||||||
] as const;
|
] as const;
|
||||||
};
|
};
|
||||||
@ -1214,7 +1422,7 @@ export const getMangaChapters = (
|
|||||||
) => {
|
) => {
|
||||||
return customInstance<MangaChapterDTO[]>(
|
return customInstance<MangaChapterDTO[]>(
|
||||||
{
|
{
|
||||||
url: `http://192.168.1.142:8080/mangas/${encodeURIComponent(String(mangaProviderId))}/chapters`,
|
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/mangas/${encodeURIComponent(String(mangaProviderId))}/chapters`,
|
||||||
method: "GET",
|
method: "GET",
|
||||||
signal,
|
signal,
|
||||||
},
|
},
|
||||||
@ -1224,7 +1432,7 @@ export const getMangaChapters = (
|
|||||||
|
|
||||||
export const getGetMangaChaptersQueryKey = (mangaProviderId?: number) => {
|
export const getGetMangaChaptersQueryKey = (mangaProviderId?: number) => {
|
||||||
return [
|
return [
|
||||||
`http://192.168.1.142:8080/mangas/${mangaProviderId}/chapters`,
|
`http://rov-lenovo.badger-pirarucu.ts.net:8080/mangas/${mangaProviderId}/chapters`,
|
||||||
] as const;
|
] as const;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1391,7 +1599,7 @@ export const getManga = (
|
|||||||
) => {
|
) => {
|
||||||
return customInstance<MangaDTO>(
|
return customInstance<MangaDTO>(
|
||||||
{
|
{
|
||||||
url: `http://192.168.1.142:8080/mangas/${encodeURIComponent(String(mangaId))}`,
|
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/mangas/${encodeURIComponent(String(mangaId))}`,
|
||||||
method: "GET",
|
method: "GET",
|
||||||
signal,
|
signal,
|
||||||
},
|
},
|
||||||
@ -1400,7 +1608,9 @@ export const getManga = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getGetMangaQueryKey = (mangaId?: number) => {
|
export const getGetMangaQueryKey = (mangaId?: number) => {
|
||||||
return [`http://192.168.1.142:8080/mangas/${mangaId}`] as const;
|
return [
|
||||||
|
`http://rov-lenovo.badger-pirarucu.ts.net:8080/mangas/${mangaId}`,
|
||||||
|
] as const;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getGetMangaQueryOptions = <
|
export const getGetMangaQueryOptions = <
|
||||||
@ -1541,7 +1751,7 @@ export const getMangaChapterImages = (
|
|||||||
) => {
|
) => {
|
||||||
return customInstance<MangaChapterImagesDTO>(
|
return customInstance<MangaChapterImagesDTO>(
|
||||||
{
|
{
|
||||||
url: `http://192.168.1.142:8080/mangas/${encodeURIComponent(String(chapterId))}/images`,
|
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/mangas/${encodeURIComponent(String(chapterId))}/images`,
|
||||||
method: "GET",
|
method: "GET",
|
||||||
signal,
|
signal,
|
||||||
},
|
},
|
||||||
@ -1550,7 +1760,9 @@ export const getMangaChapterImages = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getGetMangaChapterImagesQueryKey = (chapterId?: number) => {
|
export const getGetMangaChapterImagesQueryKey = (chapterId?: number) => {
|
||||||
return [`http://192.168.1.142:8080/mangas/${chapterId}/images`] as const;
|
return [
|
||||||
|
`http://rov-lenovo.badger-pirarucu.ts.net:8080/mangas/${chapterId}/images`,
|
||||||
|
] as const;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getGetMangaChapterImagesQueryOptions = <
|
export const getGetMangaChapterImagesQueryOptions = <
|
||||||
@ -1712,13 +1924,17 @@ export const getGenres = (
|
|||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
) => {
|
) => {
|
||||||
return customInstance<GenreDTO[]>(
|
return customInstance<GenreDTO[]>(
|
||||||
{ url: `http://192.168.1.142:8080/genres`, method: "GET", signal },
|
{
|
||||||
|
url: `http://rov-lenovo.badger-pirarucu.ts.net:8080/genres`,
|
||||||
|
method: "GET",
|
||||||
|
signal,
|
||||||
|
},
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getGetGenresQueryKey = () => {
|
export const getGetGenresQueryKey = () => {
|
||||||
return [`http://192.168.1.142:8080/genres`] as const;
|
return [`http://rov-lenovo.badger-pirarucu.ts.net:8080/genres`] as const;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getGetGenresQueryOptions = <
|
export const getGetGenresQueryOptions = <
|
||||||
|
|||||||
@ -175,7 +175,9 @@ export default function MangaDetailPage() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">Rating</p>
|
<p className="text-sm text-muted-foreground">Rating</p>
|
||||||
<p className="text-lg font-semibold text-foreground">
|
<p className="text-lg font-semibold text-foreground">
|
||||||
{mangaData.score}/5.0
|
{mangaData.score && mangaData.score > 0
|
||||||
|
? mangaData.score
|
||||||
|
: "-"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -184,7 +186,11 @@ export default function MangaDetailPage() {
|
|||||||
<BookOpen className="h-5 w-5 text-primary" />
|
<BookOpen className="h-5 w-5 text-primary" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">Chapters</p>
|
<p className="text-sm text-muted-foreground">Chapters</p>
|
||||||
{/*<p className="text-lg font-semibold text-foreground">{manga.chapters}</p>*/}
|
<p className="text-lg font-semibold text-foreground">
|
||||||
|
{mangaData.chapterCount && mangaData.chapterCount > 0
|
||||||
|
? mangaData.chapterCount
|
||||||
|
: "-"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
24
app/page.tsx
24
app/page.tsx
@ -10,6 +10,8 @@ import { ThemeToggle } from "@/components/theme-toggle";
|
|||||||
import { useGetMangas } from "@/api/mangamochi";
|
import { useGetMangas } from "@/api/mangamochi";
|
||||||
import { useAuth } from "@/contexts/auth-context";
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
import { AuthHeader } from "@/components/auth-header";
|
import { AuthHeader } from "@/components/auth-header";
|
||||||
|
import { ImportDropdown } from "@/components/import-dropdown";
|
||||||
|
import { SortOption, SortOptions } from "@/components/sort-options";
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 12;
|
const ITEMS_PER_PAGE = 12;
|
||||||
|
|
||||||
@ -20,6 +22,8 @@ export default function HomePage() {
|
|||||||
const [selectedStatus, setSelectedStatus] = useState<string[]>([]);
|
const [selectedStatus, setSelectedStatus] = useState<string[]>([]);
|
||||||
const [minRating, setMinRating] = useState(0);
|
const [minRating, setMinRating] = useState(0);
|
||||||
const [userFavorites, setUserFavorites] = useState(false);
|
const [userFavorites, setUserFavorites] = useState(false);
|
||||||
|
const [showAdultContent, setShowAdultContent] = useState(false);
|
||||||
|
const [sortOption, setSortOption] = useState<SortOption>("title-asc");
|
||||||
|
|
||||||
const { data: mangas, queryKey } = useGetMangas({
|
const { data: mangas, queryKey } = useGetMangas({
|
||||||
page: currentPage - 1,
|
page: currentPage - 1,
|
||||||
@ -29,6 +33,7 @@ export default function HomePage() {
|
|||||||
statuses: selectedStatus,
|
statuses: selectedStatus,
|
||||||
genreIds: selectedGenres,
|
genreIds: selectedGenres,
|
||||||
userFavorites,
|
userFavorites,
|
||||||
|
score: minRating,
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalPages = mangas?.totalPages;
|
const totalPages = mangas?.totalPages;
|
||||||
@ -40,10 +45,12 @@ export default function HomePage() {
|
|||||||
selectedStatus={selectedStatus}
|
selectedStatus={selectedStatus}
|
||||||
minRating={minRating}
|
minRating={minRating}
|
||||||
userFavorites={userFavorites}
|
userFavorites={userFavorites}
|
||||||
|
showAdultContent={showAdultContent}
|
||||||
onGenresChange={setSelectedGenres}
|
onGenresChange={setSelectedGenres}
|
||||||
onStatusChange={setSelectedStatus}
|
onStatusChange={setSelectedStatus}
|
||||||
onRatingChange={setMinRating}
|
onRatingChange={setMinRating}
|
||||||
onUserFavoritesChange={setUserFavorites}
|
onUserFavoritesChange={setUserFavorites}
|
||||||
|
onShowAdultContentChange={setShowAdultContent}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
@ -65,6 +72,7 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
<ImportDropdown />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<AuthHeader />
|
<AuthHeader />
|
||||||
</div>
|
</div>
|
||||||
@ -91,6 +99,22 @@ export default function HomePage() {
|
|||||||
<main className="px-8 py-8">
|
<main className="px-8 py-8">
|
||||||
{mangas?.content && mangas.content.length > 0 ? (
|
{mangas?.content && mangas.content.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
|
{mangas?.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,
|
||||||
|
)}{" "}
|
||||||
|
of {mangas.totalElements}
|
||||||
|
</p>
|
||||||
|
<SortOptions
|
||||||
|
currentSort={sortOption}
|
||||||
|
onSortChange={setSortOption}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<MangaGrid manga={mangas?.content} queryKey={queryKey} />
|
<MangaGrid manga={mangas?.content} queryKey={queryKey} />
|
||||||
|
|
||||||
{totalPages && totalPages > 1 && (
|
{totalPages && totalPages > 1 && (
|
||||||
|
|||||||
@ -7,24 +7,27 @@ import { Star, X } from "lucide-react";
|
|||||||
import { useGetGenres } from "@/api/mangamochi";
|
import { useGetGenres } from "@/api/mangamochi";
|
||||||
import { useAuth } from "@/contexts/auth-context";
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
interface FilterSidebarProps {
|
interface FilterSidebarProps {
|
||||||
selectedGenres: number[];
|
selectedGenres: number[];
|
||||||
selectedStatus: string[];
|
selectedStatus: string[];
|
||||||
minRating: number;
|
minRating: number;
|
||||||
userFavorites: boolean;
|
userFavorites: boolean;
|
||||||
|
showAdultContent: boolean;
|
||||||
onGenresChange: (genres: number[]) => void;
|
onGenresChange: (genres: number[]) => void;
|
||||||
onStatusChange: (status: string[]) => void;
|
onStatusChange: (status: string[]) => void;
|
||||||
onRatingChange: (rating: number) => void;
|
onRatingChange: (rating: number) => void;
|
||||||
onUserFavoritesChange: (favorites: boolean) => void;
|
onUserFavoritesChange: (favorites: boolean) => void;
|
||||||
|
onShowAdultContentChange: (showAdult: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUSES = ["Ongoing", "Completed", "Hiatus"];
|
const STATUSES = ["Ongoing", "Completed", "Hiatus"];
|
||||||
|
|
||||||
const RATINGS = [
|
const RATINGS = [
|
||||||
{ label: "4.5+ Stars", value: 4.5 },
|
{ label: "8.5+ Stars", value: 8.5 },
|
||||||
{ label: "4.0+ Stars", value: 4.0 },
|
{ label: "7.0+ Stars", value: 7.0 },
|
||||||
{ label: "3.5+ Stars", value: 3.5 },
|
{ label: "5.0+ Stars", value: 5.0 },
|
||||||
{ label: "All Ratings", value: 0 },
|
{ label: "All Ratings", value: 0 },
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -33,12 +36,14 @@ export function FilterSidebar({
|
|||||||
selectedStatus,
|
selectedStatus,
|
||||||
minRating,
|
minRating,
|
||||||
userFavorites,
|
userFavorites,
|
||||||
|
showAdultContent,
|
||||||
onGenresChange,
|
onGenresChange,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
onRatingChange,
|
onRatingChange,
|
||||||
onUserFavoritesChange,
|
onUserFavoritesChange,
|
||||||
|
onShowAdultContentChange,
|
||||||
}: FilterSidebarProps) {
|
}: FilterSidebarProps) {
|
||||||
const { data: genresData } = useGetGenres();
|
const { data: genresData, isPending: isPendingGenres } = useGetGenres();
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
|
|
||||||
const toggleGenre = (genre: number) => {
|
const toggleGenre = (genre: number) => {
|
||||||
@ -62,6 +67,7 @@ export function FilterSidebar({
|
|||||||
onStatusChange([]);
|
onStatusChange([]);
|
||||||
onRatingChange(0);
|
onRatingChange(0);
|
||||||
onUserFavoritesChange(false);
|
onUserFavoritesChange(false);
|
||||||
|
onShowAdultContentChange(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasActiveFilters =
|
const hasActiveFilters =
|
||||||
@ -108,6 +114,15 @@ export function FilterSidebar({
|
|||||||
onCheckedChange={onUserFavoritesChange}
|
onCheckedChange={onUserFavoritesChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center justify-between rounded-md px-3 py-2">
|
||||||
|
<label className="text-sm text-sidebar-foreground">
|
||||||
|
Show Adult Content
|
||||||
|
</label>
|
||||||
|
<Switch
|
||||||
|
checked={showAdultContent}
|
||||||
|
onCheckedChange={onShowAdultContentChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator className="bg-sidebar-border" />
|
<Separator className="bg-sidebar-border" />
|
||||||
@ -120,24 +135,26 @@ export function FilterSidebar({
|
|||||||
Genres
|
Genres
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{genresData?.map((genre) => {
|
{isPendingGenres && <Skeleton className="h-[240px] w-full" />}
|
||||||
const isSelected = selectedGenres.includes(genre.id);
|
{!isPendingGenres &&
|
||||||
return (
|
genresData?.map((genre) => {
|
||||||
<Badge
|
const isSelected = selectedGenres.includes(genre.id);
|
||||||
key={genre.id}
|
return (
|
||||||
variant={isSelected ? "default" : "outline"}
|
<Badge
|
||||||
className={`cursor-pointer transition-colors ${
|
key={genre.id}
|
||||||
isSelected
|
variant={isSelected ? "default" : "outline"}
|
||||||
? "bg-sidebar-primary text-sidebar-primary-foreground hover:bg-sidebar-primary/90"
|
className={`cursor-pointer transition-colors ${
|
||||||
: "border-sidebar-border text-sidebar-foreground hover:bg-sidebar-accent"
|
isSelected
|
||||||
}`}
|
? "bg-sidebar-primary text-sidebar-primary-foreground hover:bg-sidebar-primary/90"
|
||||||
onClick={() => toggleGenre(genre.id)}
|
: "border-sidebar-border text-sidebar-foreground hover:bg-sidebar-accent"
|
||||||
>
|
}`}
|
||||||
{genre.name}
|
onClick={() => toggleGenre(genre.id)}
|
||||||
{isSelected && <X className="ml-1 h-3 w-3" />}
|
>
|
||||||
</Badge>
|
{genre.name}
|
||||||
);
|
{isSelected && <X className="ml-1 h-3 w-3" />}
|
||||||
})}
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
60
components/import-dropdown.tsx
Normal file
60
components/import-dropdown.tsx
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type React from "react";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { 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";
|
||||||
|
|
||||||
|
export function ImportDropdown() {
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
|
||||||
|
const [mangaDexDialogOpen, setMangaDexDialogOpen] = useState(false);
|
||||||
|
const [fileImportDialogOpen, setFileImportDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className="gap-2 bg-transparent">
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="">
|
||||||
|
<DropdownMenuItem onClick={() => setMangaDexDialogOpen(true)}>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Import from MangaDex
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setFileImportDialogOpen(true)}>
|
||||||
|
<FileUp className="mr-2 h-4 w-4" />
|
||||||
|
Import from File
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<MangaDexImportDialog
|
||||||
|
mangaDexDialogOpen={mangaDexDialogOpen}
|
||||||
|
onMangaDexDialogOpenChange={setMangaDexDialogOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MangaManualImportDialog
|
||||||
|
fileImportDialogOpen={fileImportDialogOpen}
|
||||||
|
onFileImportDialogOpenChange={setFileImportDialogOpen}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -25,7 +25,6 @@ interface Manga {
|
|||||||
genres: string[];
|
genres: string[];
|
||||||
score: number;
|
score: number;
|
||||||
favorite: boolean;
|
favorite: boolean;
|
||||||
// chapters: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MangaCardProps {
|
interface MangaCardProps {
|
||||||
@ -134,8 +133,8 @@ export function MangaCard({ manga, queryKey }: MangaCardProps) {
|
|||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault(); // stop <a> default nav
|
event.preventDefault();
|
||||||
event.stopPropagation(); // stop bubbling to other elements
|
event.stopPropagation();
|
||||||
handleFavoriteClick(manga.favorite);
|
handleFavoriteClick(manga.favorite);
|
||||||
}}
|
}}
|
||||||
className="absolute left-2 top-2 h-8 w-8 rounded-full bg-background/80 backdrop-blur-sm hover:bg-background/90"
|
className="absolute left-2 top-2 h-8 w-8 rounded-full bg-background/80 backdrop-blur-sm hover:bg-background/90"
|
||||||
@ -173,7 +172,7 @@ export function MangaCard({ manga, queryKey }: MangaCardProps) {
|
|||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Star className="h-3.5 w-3.5 fill-primary text-primary" />
|
<Star className="h-3.5 w-3.5 fill-primary text-primary" />
|
||||||
<span className="text-xs font-medium text-foreground">
|
<span className="text-xs font-medium text-foreground">
|
||||||
{manga.score}
|
{manga.score && manga.score > 0 ? manga.score : "-"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
99
components/manga-dex-import-dialog.tsx
Normal file
99
components/manga-dex-import-dialog.tsx
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { useImportFromMangaDex } from "@/api/mangamochi";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
interface MangaDexImportDialogProps {
|
||||||
|
mangaDexDialogOpen: boolean;
|
||||||
|
onMangaDexDialogOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MangaDexImportDialog = ({
|
||||||
|
mangaDexDialogOpen,
|
||||||
|
onMangaDexDialogOpenChange,
|
||||||
|
}: MangaDexImportDialogProps) => {
|
||||||
|
const [mangaDexId, setMangaDexId] = useState("");
|
||||||
|
|
||||||
|
const { mutate: importMangaDex, isPending: isPendingImportMangaDex } =
|
||||||
|
useImportFromMangaDex({
|
||||||
|
mutation: {
|
||||||
|
onSuccess: () => {
|
||||||
|
setMangaDexId("");
|
||||||
|
onMangaDexDialogOpenChange(false);
|
||||||
|
toast.success("Manga imported successfully!");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleMangaDexImport = () => {
|
||||||
|
if (!mangaDexId.trim()) {
|
||||||
|
alert("Please enter a MangaDex URL or ID");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = mangaDexId;
|
||||||
|
if (mangaDexId.length > 36) {
|
||||||
|
const match = mangaDexId.match(/title\/([0-9a-fA-F-]{36})/);
|
||||||
|
if (match) {
|
||||||
|
id = match[1];
|
||||||
|
} else {
|
||||||
|
alert("Invalid MangaDex URL or ID");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id.length !== 36) {
|
||||||
|
alert("Invalid MangaDex ID");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
importMangaDex({ data: { id } });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={mangaDexDialogOpen} onOpenChange={onMangaDexDialogOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Import from MangaDex</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Enter a MangaDex manga URL or ID to import it to your library.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">MangaDex URL or ID</label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g., https://mangadex.org/title/..."
|
||||||
|
value={mangaDexId}
|
||||||
|
onChange={(e) => setMangaDexId(e.target.value)}
|
||||||
|
className="mt-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onMangaDexDialogOpenChange(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={isPendingImportMangaDex}
|
||||||
|
onClick={handleMangaDexImport}
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
178
components/manga-manual-import-dialog.tsx
Normal file
178
components/manga-manual-import-dialog.tsx
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { FileUp } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { useImportMultipleFiles } from "@/api/mangamochi";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface MangaManualImportDialogProps {
|
||||||
|
fileImportDialogOpen: boolean;
|
||||||
|
onFileImportDialogOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MangaManualImportDialog = ({
|
||||||
|
fileImportDialogOpen,
|
||||||
|
onFileImportDialogOpenChange,
|
||||||
|
}: MangaManualImportDialogProps) => {
|
||||||
|
const [malId, setMalId] = useState("");
|
||||||
|
const [dragActive, setDragActive] = useState(false);
|
||||||
|
const [files, setFiles] = useState<File[] | null>(null);
|
||||||
|
|
||||||
|
const { mutate, isPending } = useImportMultipleFiles({
|
||||||
|
mutation: {
|
||||||
|
onSuccess: () => {
|
||||||
|
setFiles(null);
|
||||||
|
setMalId("");
|
||||||
|
onFileImportDialogOpenChange(false);
|
||||||
|
toast.success("Manga imported successfully!");
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Failed to import manga."),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleFileImport = () => {
|
||||||
|
if (!files) {
|
||||||
|
alert("Please select one or more files to upload");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!malId.trim()) {
|
||||||
|
alert("Please enter a MyAnimeList manga ID");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = malId;
|
||||||
|
|
||||||
|
if (!/^\d+$/.test(malId)) {
|
||||||
|
const regex =
|
||||||
|
/https?:\/\/(?:www\.)?myanimelist\.net\/(manga)\/(\d+)(?:\/|$)/i;
|
||||||
|
const match = malId.match(regex);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
id = match[2];
|
||||||
|
} else {
|
||||||
|
alert("Invalid MyAnimeList URL or ID");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutate({ data: { malId: id, files } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrag = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.type === "dragenter" || e.type === "dragover") {
|
||||||
|
setDragActive(true);
|
||||||
|
} else if (e.type === "dragleave") {
|
||||||
|
setDragActive(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDragActive(false);
|
||||||
|
|
||||||
|
if (e.dataTransfer.files) {
|
||||||
|
setFiles(Array.from(e.dataTransfer.files));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files) {
|
||||||
|
setFiles(Array.from(e.target.files));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={fileImportDialogOpen}
|
||||||
|
onOpenChange={onFileImportDialogOpenChange}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Import from File</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Upload one or more files and provide the MyAnimeList manga URL (or
|
||||||
|
ID) to import manga data.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">
|
||||||
|
MyAnimeList Manga URL (or ID)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g., https://myanimelist.net/manga/..."
|
||||||
|
value={malId}
|
||||||
|
onChange={(e) => setMalId(e.target.value)}
|
||||||
|
className="mt-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">Upload File</label>
|
||||||
|
<div
|
||||||
|
onDragEnter={handleDrag}
|
||||||
|
onDragLeave={handleDrag}
|
||||||
|
onDragOver={handleDrag}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
className={`mt-2 rounded-lg border-2 border-dashed p-6 text-center transition-colors ${
|
||||||
|
dragActive
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-muted-foreground/25"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="file-input"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
className="hidden"
|
||||||
|
multiple
|
||||||
|
accept=".cbz"
|
||||||
|
/>
|
||||||
|
<label htmlFor="file-input" className="cursor-pointer">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<FileUp className="h-8 w-8 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{files
|
||||||
|
? files.map((file) => file.name).join(", ")
|
||||||
|
: "Drag and drop your files here"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
or click to select (.CBZ, .CBR)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
disabled={isPending}
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setFiles(null);
|
||||||
|
onFileImportDialogOpenChange(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button disabled={isPending} onClick={handleFileImport}>
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
132
components/sort-options.tsx
Normal file
132
components/sort-options.tsx
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
|
export type SortOption =
|
||||||
|
| "title-asc"
|
||||||
|
| "title-desc"
|
||||||
|
| "rating-desc"
|
||||||
|
| "rating-asc"
|
||||||
|
| "date-newest"
|
||||||
|
| "date-oldest"
|
||||||
|
| "status";
|
||||||
|
|
||||||
|
interface SortOptionsProps {
|
||||||
|
currentSort: SortOption;
|
||||||
|
onSortChange: (sort: SortOption) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SortOptions({ currentSort, onSortChange }: SortOptionsProps) {
|
||||||
|
const getSortLabel = () => {
|
||||||
|
switch (currentSort) {
|
||||||
|
case "title-asc":
|
||||||
|
return "Title (A-Z)";
|
||||||
|
case "title-desc":
|
||||||
|
return "Title (Z-A)";
|
||||||
|
case "rating-desc":
|
||||||
|
return "Rating (High to Low)";
|
||||||
|
case "rating-asc":
|
||||||
|
return "Rating (Low to High)";
|
||||||
|
case "date-newest":
|
||||||
|
return "Newest First";
|
||||||
|
case "date-oldest":
|
||||||
|
return "Oldest First";
|
||||||
|
case "status":
|
||||||
|
return "Status";
|
||||||
|
default:
|
||||||
|
return "Sort by";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className="gap-2 bg-transparent">
|
||||||
|
<ArrowUpDown className="h-4 w-4" />
|
||||||
|
{getSortLabel()}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="w-56">
|
||||||
|
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
<DropdownMenuLabel className="text-xs font-semibold text-muted-foreground">
|
||||||
|
Title
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onSortChange("title-asc")}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<ArrowUp className="mr-2 h-4 w-4" />
|
||||||
|
Title (A-Z)
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onSortChange("title-desc")}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<ArrowDown className="mr-2 h-4 w-4" />
|
||||||
|
Title (Z-A)
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuLabel className="text-xs font-semibold text-muted-foreground">
|
||||||
|
Rating
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onSortChange("rating-desc")}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<ArrowDown className="mr-2 h-4 w-4" />
|
||||||
|
Rating (High to Low)
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onSortChange("rating-asc")}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<ArrowUp className="mr-2 h-4 w-4" />
|
||||||
|
Rating (Low to High)
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuLabel className="text-xs font-semibold text-muted-foreground">
|
||||||
|
Publication Date
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onSortChange("date-newest")}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<ArrowDown className="mr-2 h-4 w-4" />
|
||||||
|
Newest First
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onSortChange("date-oldest")}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<ArrowUp className="mr-2 h-4 w-4" />
|
||||||
|
Oldest First
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuLabel className="text-xs font-semibold text-muted-foreground">
|
||||||
|
Status
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onSortChange("status")}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<ArrowUpDown className="mr-2 h-4 w-4" />
|
||||||
|
Ongoing First
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -7,7 +7,7 @@ module.exports = {
|
|||||||
target: "api/mangamochi.ts",
|
target: "api/mangamochi.ts",
|
||||||
client: "react-query",
|
client: "react-query",
|
||||||
httpClient: "axios",
|
httpClient: "axios",
|
||||||
baseUrl: "http://192.168.1.142:8080",
|
baseUrl: "http://rov-lenovo.badger-pirarucu.ts.net:8080",
|
||||||
urlEncodeParameters: true,
|
urlEncodeParameters: true,
|
||||||
override: {
|
override: {
|
||||||
mutator: {
|
mutator: {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user