diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6c604a7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +node_modules +npm-debug.log +Dockerfile +.dockerignore + diff --git a/api/mangamochi.ts b/api/mangamochi.ts index b2cc2b3..a3b6704 100644 --- a/api/mangamochi.ts +++ b/api/mangamochi.ts @@ -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( { - 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( { - 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( { - 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( { - 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( { - 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( { - 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( { - 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( { - 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 = ( 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, + signal?: AbortSignal, +) => { + const formData = new FormData(); + formData.append(`malId`, importMultipleFilesBody.malId); + importMultipleFilesBody.files.forEach((value) => + formData.append(`files`, value), + ); + + return customInstance( + { + 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>, + TError, + { data: ImportMultipleFilesBody }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + 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>, + { data: ImportMultipleFilesBody } + > = (props) => { + const { data } = props ?? {}; + + return importMultipleFiles(data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type ImportMultipleFilesMutationResult = NonNullable< + Awaited> +>; +export type ImportMultipleFilesMutationBody = ImportMultipleFilesBody; +export type ImportMultipleFilesMutationError = unknown; + +/** + * @summary Upload multiple files + */ +export const useImportMultipleFiles = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: ImportMultipleFilesBody }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + 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, + signal?: AbortSignal, +) => { + return customInstance( + { + 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>, + TError, + { data: ImportMangaDexRequestDTO }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + 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>, + { data: ImportMangaDexRequestDTO } + > = (props) => { + const { data } = props ?? {}; + + return importFromMangaDex(data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type ImportFromMangaDexMutationResult = NonNullable< + Awaited> +>; +export type ImportFromMangaDexMutationBody = ImportMangaDexRequestDTO; +export type ImportFromMangaDexMutationError = unknown; + +/** + * @summary Import manga from MangaDex + */ +export const useImportFromMangaDex = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: ImportMangaDexRequestDTO }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + 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( { - 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( { - 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( - { 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( { - 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( { - 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( { - 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( - { 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 = < diff --git a/app/manga/[id]/page.tsx b/app/manga/[id]/page.tsx index b7a81ce..625cfe6 100644 --- a/app/manga/[id]/page.tsx +++ b/app/manga/[id]/page.tsx @@ -175,7 +175,9 @@ export default function MangaDetailPage() {

Rating

- {mangaData.score}/5.0 + {mangaData.score && mangaData.score > 0 + ? mangaData.score + : "-"}

@@ -184,7 +186,11 @@ export default function MangaDetailPage() {

Chapters

- {/*

{manga.chapters}

*/} +

+ {mangaData.chapterCount && mangaData.chapterCount > 0 + ? mangaData.chapterCount + : "-"} +

diff --git a/app/page.tsx b/app/page.tsx index e6e489d..571a60a 100644 --- a/app/page.tsx +++ b/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([]); const [minRating, setMinRating] = useState(0); const [userFavorites, setUserFavorites] = useState(false); + const [showAdultContent, setShowAdultContent] = useState(false); + const [sortOption, setSortOption] = useState("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() {
+
@@ -91,6 +99,22 @@ export default function HomePage() {
{mangas?.content && mangas.content.length > 0 ? ( <> + {mangas?.totalElements && ( +
+

+ Showing {(currentPage - 1) * ITEMS_PER_PAGE + 1} to{" "} + {Math.min( + currentPage * ITEMS_PER_PAGE, + mangas.totalElements, + )}{" "} + of {mangas.totalElements} +

+ +
+ )} {totalPages && totalPages > 1 && ( diff --git a/components/filter-sidebar.tsx b/components/filter-sidebar.tsx index cbef2b9..3158783 100644 --- a/components/filter-sidebar.tsx +++ b/components/filter-sidebar.tsx @@ -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} /> +
+ + +
@@ -120,24 +135,26 @@ export function FilterSidebar({ Genres
- {genresData?.map((genre) => { - const isSelected = selectedGenres.includes(genre.id); - return ( - toggleGenre(genre.id)} - > - {genre.name} - {isSelected && } - - ); - })} + {isPendingGenres && } + {!isPendingGenres && + genresData?.map((genre) => { + const isSelected = selectedGenres.includes(genre.id); + return ( + toggleGenre(genre.id)} + > + {genre.name} + {isSelected && } + + ); + })}
diff --git a/components/import-dropdown.tsx b/components/import-dropdown.tsx new file mode 100644 index 0000000..3f2f44b --- /dev/null +++ b/components/import-dropdown.tsx @@ -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 ( + <> + + + + + + setMangaDexDialogOpen(true)}> + + Import from MangaDex + + setFileImportDialogOpen(true)}> + + Import from File + + + + + + + + + ); +} diff --git a/components/manga-card.tsx b/components/manga-card.tsx index 40ca106..69da17d 100644 --- a/components/manga-card.tsx +++ b/components/manga-card.tsx @@ -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 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) {
- {manga.score} + {manga.score && manga.score > 0 ? manga.score : "-"}
diff --git a/components/manga-dex-import-dialog.tsx b/components/manga-dex-import-dialog.tsx new file mode 100644 index 0000000..1245023 --- /dev/null +++ b/components/manga-dex-import-dialog.tsx @@ -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 ( + + + + Import from MangaDex + + Enter a MangaDex manga URL or ID to import it to your library. + + +
+
+ + setMangaDexId(e.target.value)} + className="mt-2" + /> +
+
+ + + + +
+
+ ); +}; diff --git a/components/manga-manual-import-dialog.tsx b/components/manga-manual-import-dialog.tsx new file mode 100644 index 0000000..31b9714 --- /dev/null +++ b/components/manga-manual-import-dialog.tsx @@ -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(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) => { + if (e.target.files) { + setFiles(Array.from(e.target.files)); + } + }; + + return ( + + + + Import from File + + Upload one or more files and provide the MyAnimeList manga URL (or + ID) to import manga data. + + +
+
+ + setMalId(e.target.value)} + className="mt-2" + /> +
+
+ +
+ + +
+
+
+ + + + +
+
+ ); +}; diff --git a/components/sort-options.tsx b/components/sort-options.tsx new file mode 100644 index 0000000..01e7120 --- /dev/null +++ b/components/sort-options.tsx @@ -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 ( + + + + + + Sort by + + + + Title + + onSortChange("title-asc")} + className="cursor-pointer" + > + + Title (A-Z) + + onSortChange("title-desc")} + className="cursor-pointer" + > + + Title (Z-A) + + + + + Rating + + onSortChange("rating-desc")} + className="cursor-pointer" + > + + Rating (High to Low) + + onSortChange("rating-asc")} + className="cursor-pointer" + > + + Rating (Low to High) + + + + + Publication Date + + onSortChange("date-newest")} + className="cursor-pointer" + > + + Newest First + + onSortChange("date-oldest")} + className="cursor-pointer" + > + + Oldest First + + + + + Status + + onSortChange("status")} + className="cursor-pointer" + > + + Ongoing First + + + + ); +} diff --git a/orval.config.ts b/orval.config.ts index 1e26d46..5f4cbbb 100644 --- a/orval.config.ts +++ b/orval.config.ts @@ -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: {