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";
|
||||
|
||||
import { customInstance } from "./api";
|
||||
export interface ImportMangaDexRequestDTO {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ImportMangaDexResponseDTO {
|
||||
id: number;
|
||||
}
|
||||
|
||||
export interface RegistrationRequestDTO {
|
||||
name?: string;
|
||||
email?: string;
|
||||
@ -79,9 +87,9 @@ export interface PageMangaListDTO {
|
||||
|
||||
export interface PageableObject {
|
||||
offset?: number;
|
||||
paged?: boolean;
|
||||
pageNumber?: number;
|
||||
pageSize?: number;
|
||||
paged?: boolean;
|
||||
sort?: SortObject;
|
||||
unpaged?: boolean;
|
||||
}
|
||||
@ -115,6 +123,7 @@ export interface MangaDTO {
|
||||
authors: string[];
|
||||
score: number;
|
||||
providers: MangaProviderDTO[];
|
||||
chapterCount: number;
|
||||
}
|
||||
|
||||
export interface MangaProviderDTO {
|
||||
@ -151,11 +160,19 @@ export const DownloadChapterArchiveArchiveFileType = {
|
||||
CBR: "CBR",
|
||||
} as const;
|
||||
|
||||
export type ImportMultipleFilesBody = {
|
||||
/** @minLength 1 */
|
||||
malId: string;
|
||||
/** List of files to upload */
|
||||
files: Blob[];
|
||||
};
|
||||
|
||||
export type GetMangasParams = {
|
||||
searchQuery?: string;
|
||||
genreIds?: number[];
|
||||
statuses?: string[];
|
||||
userFavorites?: boolean;
|
||||
score?: number;
|
||||
/**
|
||||
* Zero-based page index (0..N)
|
||||
* @minimum 0
|
||||
@ -185,7 +202,7 @@ export const fetchMangaChapters = (
|
||||
) => {
|
||||
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",
|
||||
signal,
|
||||
},
|
||||
@ -273,7 +290,7 @@ export const setUnfavorite = (
|
||||
) => {
|
||||
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",
|
||||
signal,
|
||||
},
|
||||
@ -361,7 +378,7 @@ export const setFavorite = (
|
||||
) => {
|
||||
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",
|
||||
signal,
|
||||
},
|
||||
@ -449,7 +466,7 @@ export const markAsRead = (
|
||||
) => {
|
||||
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",
|
||||
signal,
|
||||
},
|
||||
@ -536,7 +553,7 @@ export const updateMangaInfo = (
|
||||
) => {
|
||||
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",
|
||||
signal,
|
||||
},
|
||||
@ -623,7 +640,7 @@ export const downloadAllChapters = (
|
||||
) => {
|
||||
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",
|
||||
signal,
|
||||
},
|
||||
@ -710,7 +727,7 @@ export const fetchChapter = (
|
||||
) => {
|
||||
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",
|
||||
signal,
|
||||
},
|
||||
@ -798,7 +815,7 @@ export const downloadChapterArchive = (
|
||||
) => {
|
||||
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",
|
||||
params,
|
||||
responseType: "blob",
|
||||
@ -877,6 +894,192 @@ export const useDownloadChapterArchive = <TError = unknown, TContext = unknown>(
|
||||
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.
|
||||
* @summary Register user
|
||||
@ -888,7 +1091,7 @@ export const registerUser = (
|
||||
) => {
|
||||
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",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
data: registrationRequestDTO,
|
||||
@ -978,7 +1181,7 @@ export const authenticateUser = (
|
||||
) => {
|
||||
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",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
data: authenticationRequestDTO,
|
||||
@ -1067,14 +1270,19 @@ export const getMangas = (
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
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,
|
||||
);
|
||||
};
|
||||
|
||||
export const getGetMangasQueryKey = (params?: GetMangasParams) => {
|
||||
return [
|
||||
`http://192.168.1.142:8080/mangas`,
|
||||
`http://rov-lenovo.badger-pirarucu.ts.net:8080/mangas`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
};
|
||||
@ -1214,7 +1422,7 @@ export const getMangaChapters = (
|
||||
) => {
|
||||
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",
|
||||
signal,
|
||||
},
|
||||
@ -1224,7 +1432,7 @@ export const getMangaChapters = (
|
||||
|
||||
export const getGetMangaChaptersQueryKey = (mangaProviderId?: number) => {
|
||||
return [
|
||||
`http://192.168.1.142:8080/mangas/${mangaProviderId}/chapters`,
|
||||
`http://rov-lenovo.badger-pirarucu.ts.net:8080/mangas/${mangaProviderId}/chapters`,
|
||||
] as const;
|
||||
};
|
||||
|
||||
@ -1391,7 +1599,7 @@ export const getManga = (
|
||||
) => {
|
||||
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",
|
||||
signal,
|
||||
},
|
||||
@ -1400,7 +1608,9 @@ export const getManga = (
|
||||
};
|
||||
|
||||
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 = <
|
||||
@ -1541,7 +1751,7 @@ export const getMangaChapterImages = (
|
||||
) => {
|
||||
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",
|
||||
signal,
|
||||
},
|
||||
@ -1550,7 +1760,9 @@ export const getMangaChapterImages = (
|
||||
};
|
||||
|
||||
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 = <
|
||||
@ -1712,13 +1924,17 @@ export const getGenres = (
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
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,
|
||||
);
|
||||
};
|
||||
|
||||
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 = <
|
||||
|
||||
@ -175,7 +175,9 @@ export default function MangaDetailPage() {
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Rating</p>
|
||||
<p className="text-lg font-semibold text-foreground">
|
||||
{mangaData.score}/5.0
|
||||
{mangaData.score && mangaData.score > 0
|
||||
? mangaData.score
|
||||
: "-"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -184,7 +186,11 @@ export default function MangaDetailPage() {
|
||||
<BookOpen className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<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>
|
||||
|
||||
|
||||
24
app/page.tsx
24
app/page.tsx
@ -10,6 +10,8 @@ import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { useGetMangas } from "@/api/mangamochi";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { AuthHeader } from "@/components/auth-header";
|
||||
import { ImportDropdown } from "@/components/import-dropdown";
|
||||
import { SortOption, SortOptions } from "@/components/sort-options";
|
||||
|
||||
const ITEMS_PER_PAGE = 12;
|
||||
|
||||
@ -20,6 +22,8 @@ export default function HomePage() {
|
||||
const [selectedStatus, setSelectedStatus] = useState<string[]>([]);
|
||||
const [minRating, setMinRating] = useState(0);
|
||||
const [userFavorites, setUserFavorites] = useState(false);
|
||||
const [showAdultContent, setShowAdultContent] = useState(false);
|
||||
const [sortOption, setSortOption] = useState<SortOption>("title-asc");
|
||||
|
||||
const { data: mangas, queryKey } = useGetMangas({
|
||||
page: currentPage - 1,
|
||||
@ -29,6 +33,7 @@ export default function HomePage() {
|
||||
statuses: selectedStatus,
|
||||
genreIds: selectedGenres,
|
||||
userFavorites,
|
||||
score: minRating,
|
||||
});
|
||||
|
||||
const totalPages = mangas?.totalPages;
|
||||
@ -40,10 +45,12 @@ export default function HomePage() {
|
||||
selectedStatus={selectedStatus}
|
||||
minRating={minRating}
|
||||
userFavorites={userFavorites}
|
||||
showAdultContent={showAdultContent}
|
||||
onGenresChange={setSelectedGenres}
|
||||
onStatusChange={setSelectedStatus}
|
||||
onRatingChange={setMinRating}
|
||||
onUserFavoritesChange={setUserFavorites}
|
||||
onShowAdultContentChange={setShowAdultContent}
|
||||
/>
|
||||
|
||||
{/* Main Content */}
|
||||
@ -65,6 +72,7 @@ export default function HomePage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<ImportDropdown />
|
||||
<ThemeToggle />
|
||||
<AuthHeader />
|
||||
</div>
|
||||
@ -91,6 +99,22 @@ export default function HomePage() {
|
||||
<main className="px-8 py-8">
|
||||
{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} />
|
||||
|
||||
{totalPages && totalPages > 1 && (
|
||||
|
||||
@ -7,24 +7,27 @@ import { Star, X } from "lucide-react";
|
||||
import { useGetGenres } from "@/api/mangamochi";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
interface FilterSidebarProps {
|
||||
selectedGenres: number[];
|
||||
selectedStatus: string[];
|
||||
minRating: number;
|
||||
userFavorites: boolean;
|
||||
showAdultContent: boolean;
|
||||
onGenresChange: (genres: number[]) => void;
|
||||
onStatusChange: (status: string[]) => void;
|
||||
onRatingChange: (rating: number) => void;
|
||||
onUserFavoritesChange: (favorites: boolean) => void;
|
||||
onShowAdultContentChange: (showAdult: boolean) => void;
|
||||
}
|
||||
|
||||
const STATUSES = ["Ongoing", "Completed", "Hiatus"];
|
||||
|
||||
const RATINGS = [
|
||||
{ label: "4.5+ Stars", value: 4.5 },
|
||||
{ label: "4.0+ Stars", value: 4.0 },
|
||||
{ label: "3.5+ Stars", value: 3.5 },
|
||||
{ label: "8.5+ Stars", value: 8.5 },
|
||||
{ label: "7.0+ Stars", value: 7.0 },
|
||||
{ label: "5.0+ Stars", value: 5.0 },
|
||||
{ label: "All Ratings", value: 0 },
|
||||
];
|
||||
|
||||
@ -33,12 +36,14 @@ export function FilterSidebar({
|
||||
selectedStatus,
|
||||
minRating,
|
||||
userFavorites,
|
||||
showAdultContent,
|
||||
onGenresChange,
|
||||
onStatusChange,
|
||||
onRatingChange,
|
||||
onUserFavoritesChange,
|
||||
onShowAdultContentChange,
|
||||
}: FilterSidebarProps) {
|
||||
const { data: genresData } = useGetGenres();
|
||||
const { data: genresData, isPending: isPendingGenres } = useGetGenres();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const toggleGenre = (genre: number) => {
|
||||
@ -62,6 +67,7 @@ export function FilterSidebar({
|
||||
onStatusChange([]);
|
||||
onRatingChange(0);
|
||||
onUserFavoritesChange(false);
|
||||
onShowAdultContentChange(false);
|
||||
};
|
||||
|
||||
const hasActiveFilters =
|
||||
@ -108,6 +114,15 @@ export function FilterSidebar({
|
||||
onCheckedChange={onUserFavoritesChange}
|
||||
/>
|
||||
</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>
|
||||
|
||||
<Separator className="bg-sidebar-border" />
|
||||
@ -120,7 +135,9 @@ export function FilterSidebar({
|
||||
Genres
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{genresData?.map((genre) => {
|
||||
{isPendingGenres && <Skeleton className="h-[240px] w-full" />}
|
||||
{!isPendingGenres &&
|
||||
genresData?.map((genre) => {
|
||||
const isSelected = selectedGenres.includes(genre.id);
|
||||
return (
|
||||
<Badge
|
||||
|
||||
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[];
|
||||
score: number;
|
||||
favorite: boolean;
|
||||
// chapters: number
|
||||
}
|
||||
|
||||
interface MangaCardProps {
|
||||
@ -134,8 +133,8 @@ export function MangaCard({ manga, queryKey }: MangaCardProps) {
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={(event) => {
|
||||
event.preventDefault(); // stop <a> default nav
|
||||
event.stopPropagation(); // stop bubbling to other elements
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
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"
|
||||
@ -173,7 +172,7 @@ export function MangaCard({ manga, queryKey }: MangaCardProps) {
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="h-3.5 w-3.5 fill-primary text-primary" />
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{manga.score}
|
||||
{manga.score && manga.score > 0 ? manga.score : "-"}
|
||||
</span>
|
||||
</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",
|
||||
client: "react-query",
|
||||
httpClient: "axios",
|
||||
baseUrl: "http://192.168.1.142:8080",
|
||||
baseUrl: "http://rov-lenovo.badger-pirarucu.ts.net:8080",
|
||||
urlEncodeParameters: true,
|
||||
override: {
|
||||
mutator: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user