feat: implement manga import functionality and enhance filter options

This commit is contained in:
Rodrigo Verdiani 2025-10-24 08:31:07 -03:00
parent 650891722e
commit 112dfc7325
11 changed files with 786 additions and 50 deletions

5
.dockerignore Normal file
View File

@ -0,0 +1,5 @@
node_modules
npm-debug.log
Dockerfile
.dockerignore

View File

@ -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 = <

View File

@ -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>

View File

@ -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 && (

View File

@ -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,24 +135,26 @@ export function FilterSidebar({
Genres
</h3>
<div className="flex flex-wrap gap-2">
{genresData?.map((genre) => {
const isSelected = selectedGenres.includes(genre.id);
return (
<Badge
key={genre.id}
variant={isSelected ? "default" : "outline"}
className={`cursor-pointer transition-colors ${
isSelected
? "bg-sidebar-primary text-sidebar-primary-foreground hover:bg-sidebar-primary/90"
: "border-sidebar-border text-sidebar-foreground hover:bg-sidebar-accent"
}`}
onClick={() => toggleGenre(genre.id)}
>
{genre.name}
{isSelected && <X className="ml-1 h-3 w-3" />}
</Badge>
);
})}
{isPendingGenres && <Skeleton className="h-[240px] w-full" />}
{!isPendingGenres &&
genresData?.map((genre) => {
const isSelected = selectedGenres.includes(genre.id);
return (
<Badge
key={genre.id}
variant={isSelected ? "default" : "outline"}
className={`cursor-pointer transition-colors ${
isSelected
? "bg-sidebar-primary text-sidebar-primary-foreground hover:bg-sidebar-primary/90"
: "border-sidebar-border text-sidebar-foreground hover:bg-sidebar-accent"
}`}
onClick={() => toggleGenre(genre.id)}
>
{genre.name}
{isSelected && <X className="ml-1 h-3 w-3" />}
</Badge>
);
})}
</div>
</div>

View 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}
/>
</>
);
}

View File

@ -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>

View 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>
);
};

View 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
View 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>
);
}

View File

@ -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: {