Compare commits

...

2 Commits

Author SHA1 Message Date
rov
1cdfc905e4 Merge pull request 'feature(manga): add follow and unfollow functionality for manga' (#11) from feature/manga-follow into main
All checks were successful
ci/woodpecker/push/pipeline Pipeline was successful
Reviewed-on: #11
2025-11-12 22:04:22 -03:00
246c6023d7 feature(manga): add follow and unfollow functionality for manga
All checks were successful
ci/woodpecker/pr/pipeline Pipeline was successful
2025-11-12 20:40:20 -03:00
6 changed files with 407 additions and 103 deletions

View File

@ -4,10 +4,6 @@
* OpenAPI definition
* OpenAPI spec version: v0
*/
export interface UpdateMangaDataCommand {
mangaId?: number;
}
export interface DefaultResponseDTOVoid {
timestamp?: string;
data?: unknown;
@ -103,9 +99,9 @@ export interface PageMangaListDTO {
export interface PageableObject {
offset?: number;
paged?: boolean;
pageNumber?: number;
pageSize?: number;
paged?: boolean;
sort?: SortObject;
unpaged?: boolean;
}
@ -152,6 +148,8 @@ export interface MangaDTO {
score: number;
providers: MangaProviderDTO[];
chapterCount: number;
favorite: boolean;
following: boolean;
}
export interface MangaProviderDTO {
@ -173,6 +171,8 @@ export interface MangaChapterImagesDTO {
id: number;
/** @minLength 1 */
mangaTitle: string;
previousChapterId?: number;
nextChapterId?: number;
chapterImageKeys: string[];
}

View File

@ -1,86 +0,0 @@
/**
* Generated by orval v7.15.0 🍺
* Do not edit manually.
* OpenAPI definition
* OpenAPI spec version: v0
*/
import {
useMutation
} from '@tanstack/react-query';
import type {
MutationFunction,
QueryClient,
UseMutationOptions,
UseMutationResult
} from '@tanstack/react-query';
import type {
UpdateMangaDataCommand
} from '../api.schemas';
import { customInstance } from '../../api';
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
export const sendRecord = (
updateMangaDataCommand: UpdateMangaDataCommand,
options?: SecondParameter<typeof customInstance>,signal?: AbortSignal
) => {
return customInstance<string>(
{url: `/records`, method: 'POST',
headers: {'Content-Type': 'application/json', },
data: updateMangaDataCommand, signal
},
options);
}
export const getSendRecordMutationOptions = <TError = unknown,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof sendRecord>>, TError,{data: UpdateMangaDataCommand}, TContext>, request?: SecondParameter<typeof customInstance>}
): UseMutationOptions<Awaited<ReturnType<typeof sendRecord>>, TError,{data: UpdateMangaDataCommand}, TContext> => {
const mutationKey = ['sendRecord'];
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 sendRecord>>, {data: UpdateMangaDataCommand}> = (props) => {
const {data} = props ?? {};
return sendRecord(data,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type SendRecordMutationResult = NonNullable<Awaited<ReturnType<typeof sendRecord>>>
export type SendRecordMutationBody = UpdateMangaDataCommand
export type SendRecordMutationError = unknown
export const useSendRecord = <TError = unknown,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof sendRecord>>, TError,{data: UpdateMangaDataCommand}, TContext>, request?: SecondParameter<typeof customInstance>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof sendRecord>>,
TError,
{data: UpdateMangaDataCommand},
TContext
> => {
const mutationOptions = getSendRecordMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}

View File

@ -0,0 +1,154 @@
/**
* Generated by orval v7.15.0 🍺
* Do not edit manually.
* OpenAPI definition
* OpenAPI spec version: v0
*/
import {
useMutation
} from '@tanstack/react-query';
import type {
MutationFunction,
QueryClient,
UseMutationOptions,
UseMutationResult
} from '@tanstack/react-query';
import type {
DefaultResponseDTOVoid
} from '../api.schemas';
import { customInstance } from '../../api';
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
/**
* Queue the retrieval of the manga lists from the content providers
* @summary Queue update manga list
*/
export const updateMangaList = (
options?: SecondParameter<typeof customInstance>,signal?: AbortSignal
) => {
return customInstance<DefaultResponseDTOVoid>(
{url: `/management/update-manga-list`, method: 'POST', signal
},
options);
}
export const getUpdateMangaListMutationOptions = <TError = unknown,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateMangaList>>, TError,void, TContext>, request?: SecondParameter<typeof customInstance>}
): UseMutationOptions<Awaited<ReturnType<typeof updateMangaList>>, TError,void, TContext> => {
const mutationKey = ['updateMangaList'];
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 updateMangaList>>, void> = () => {
return updateMangaList(requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type UpdateMangaListMutationResult = NonNullable<Awaited<ReturnType<typeof updateMangaList>>>
export type UpdateMangaListMutationError = unknown
/**
* @summary Queue update manga list
*/
export const useUpdateMangaList = <TError = unknown,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateMangaList>>, TError,void, TContext>, request?: SecondParameter<typeof customInstance>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof updateMangaList>>,
TError,
void,
TContext
> => {
const mutationOptions = getUpdateMangaListMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}
/**
* Triggers the cleanup of untracked S3 images
* @summary Cleanup unused S3 images
*/
export const imageCleanup = (
options?: SecondParameter<typeof customInstance>,signal?: AbortSignal
) => {
return customInstance<DefaultResponseDTOVoid>(
{url: `/management/image-cleanup`, method: 'POST', signal
},
options);
}
export const getImageCleanupMutationOptions = <TError = unknown,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof imageCleanup>>, TError,void, TContext>, request?: SecondParameter<typeof customInstance>}
): UseMutationOptions<Awaited<ReturnType<typeof imageCleanup>>, TError,void, TContext> => {
const mutationKey = ['imageCleanup'];
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 imageCleanup>>, void> = () => {
return imageCleanup(requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type ImageCleanupMutationResult = NonNullable<Awaited<ReturnType<typeof imageCleanup>>>
export type ImageCleanupMutationError = unknown
/**
* @summary Cleanup unused S3 images
*/
export const useImageCleanup = <TError = unknown,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof imageCleanup>>, TError,void, TContext>, request?: SecondParameter<typeof customInstance>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof imageCleanup>>,
TError,
void,
TContext
> => {
const mutationOptions = getImageCleanupMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}

View File

@ -102,6 +102,132 @@ export const useFetchMangaChapters = <TError = unknown,
return useMutation(mutationOptions, queryClient);
}
/**
* Unfollow the manga specified by its ID.
* @summary Unfollow the manga specified by its ID
*/
export const unfollowManga = (
mangaId: number,
options?: SecondParameter<typeof customInstance>,signal?: AbortSignal
) => {
return customInstance<DefaultResponseDTOVoid>(
{url: `/mangas/${encodeURIComponent(String(mangaId))}/unfollowManga`, method: 'POST', signal
},
options);
}
export const getUnfollowMangaMutationOptions = <TError = unknown,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof unfollowManga>>, TError,{mangaId: number}, TContext>, request?: SecondParameter<typeof customInstance>}
): UseMutationOptions<Awaited<ReturnType<typeof unfollowManga>>, TError,{mangaId: number}, TContext> => {
const mutationKey = ['unfollowManga'];
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 unfollowManga>>, {mangaId: number}> = (props) => {
const {mangaId} = props ?? {};
return unfollowManga(mangaId,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type UnfollowMangaMutationResult = NonNullable<Awaited<ReturnType<typeof unfollowManga>>>
export type UnfollowMangaMutationError = unknown
/**
* @summary Unfollow the manga specified by its ID
*/
export const useUnfollowManga = <TError = unknown,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof unfollowManga>>, TError,{mangaId: number}, TContext>, request?: SecondParameter<typeof customInstance>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof unfollowManga>>,
TError,
{mangaId: number},
TContext
> => {
const mutationOptions = getUnfollowMangaMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}
/**
* Follow the manga specified by its ID.
* @summary Follow the manga specified by its ID
*/
export const followManga = (
mangaId: number,
options?: SecondParameter<typeof customInstance>,signal?: AbortSignal
) => {
return customInstance<DefaultResponseDTOVoid>(
{url: `/mangas/${encodeURIComponent(String(mangaId))}/followManga`, method: 'POST', signal
},
options);
}
export const getFollowMangaMutationOptions = <TError = unknown,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof followManga>>, TError,{mangaId: number}, TContext>, request?: SecondParameter<typeof customInstance>}
): UseMutationOptions<Awaited<ReturnType<typeof followManga>>, TError,{mangaId: number}, TContext> => {
const mutationKey = ['followManga'];
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 followManga>>, {mangaId: number}> = (props) => {
const {mangaId} = props ?? {};
return followManga(mangaId,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type FollowMangaMutationResult = NonNullable<Awaited<ReturnType<typeof followManga>>>
export type FollowMangaMutationError = unknown
/**
* @summary Follow the manga specified by its ID
*/
export const useFollowManga = <TError = unknown,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof followManga>>, TError,{mangaId: number}, TContext>, request?: SecondParameter<typeof customInstance>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof followManga>>,
TError,
{mangaId: number},
TContext
> => {
const mutationOptions = getFollowMangaMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}
/**
* Retrieve a list of mangas with their details.
* @summary Get a list of mangas
*/

View File

@ -32,10 +32,9 @@ export const MangaDexImportDialog = ({
mangaDexDialogOpen,
onMangaDexDialogOpenChange,
}: MangaDexImportDialogProps) => {
const formSchema = z
.object({
value: z.string().min(1, "Please enter a MangaDex ID or URL."),
});
const formSchema = z.object({
value: z.string().min(1, "Please enter a MangaDex ID or URL."),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),

View File

@ -1,18 +1,27 @@
import { useQueryClient } from "@tanstack/react-query";
import {
ArrowLeft,
Bell,
BellOff,
BookOpen,
Calendar,
ChevronDown,
Database,
Heart,
Star,
} from "lucide-react";
import { useState } from "react";
import { useCallback, useState } from "react";
import { useNavigate, useParams } from "react-router";
import { toast } from "sonner";
import {
useSetFavorite,
useSetUnfavorite,
} from "@/api/generated/favorite-mangas/favorite-mangas.ts";
import {
useFetchMangaChapters,
useFollowManga,
useGetManga,
useUnfollowManga,
} from "@/api/generated/manga/manga.ts";
import { useFetchAllChapters } from "@/api/generated/manga-chapter/manga-chapter.ts";
import { ThemeToggle } from "@/components/ThemeToggle.tsx";
@ -24,10 +33,12 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible.tsx";
import { useAuth } from "@/contexts/AuthContext.tsx";
import { MangaChapter } from "@/features/manga/MangaChapter.tsx";
import { formatToTwoDigitsDateRange } from "@/utils/dateFormatter.ts";
const Manga = () => {
const { isAuthenticated } = useAuth();
const navigate = useNavigate();
const params = useParams();
const mangaId = Number(params.mangaId);
@ -53,6 +64,65 @@ const Manga = () => {
const [openProviders, setOpenProviders] = useState<Set<number>>(new Set());
const { mutate: mutateFavorite, isPending: isPendingFavorite } =
useSetFavorite({
mutation: {
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
},
});
const { mutate: mutateUnfavorite, isPending: isPendingUnfavorite } =
useSetUnfavorite({
mutation: {
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
},
});
const isPendingFavoriteChange = isPendingFavorite || isPendingUnfavorite;
const handleFavoriteClick = useCallback(
(isFavorite: boolean) => {
isFavorite
? mutateUnfavorite({ id: mangaData?.data?.id ?? -1 })
: mutateFavorite({ id: mangaData?.data?.id ?? -1 });
},
[mutateUnfavorite, mutateFavorite, mangaData?.data?.id],
);
const { mutate: mutateFollow, isPending: isPendingFollow } = useFollowManga({
mutation: {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey });
toast.success(
"We will notify you when new content if available for this manga.",
);
},
},
});
const { mutate: mutateUnfollow, isPending: isPendingUnfollow } =
useUnfollowManga({
mutation: {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey });
toast.success(
"You will no longer received notifications for this manga.",
);
},
},
});
const isPendingFollowChange = isPendingFollow || isPendingUnfollow;
const handleFollowClick = useCallback(
(isFollowing: boolean) => {
isFollowing
? mutateUnfollow({ mangaId: mangaData?.data?.id ?? -1 })
: mutateFollow({ mangaId: mangaData?.data?.id ?? -1 });
},
[mangaData?.data?.id, mutateUnfollow, mutateFollow],
);
if (!mangaData) {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
@ -125,16 +195,57 @@ const Manga = () => {
{/* Details */}
<div className="space-y-6">
<div>
<div className="mb-3 flex items-start justify-between gap-4">
<div className="mb-3 flex items-center justify-between gap-4">
<h1 className="text-balance text-4xl font-bold tracking-tight text-foreground">
{mangaData.data?.title}
</h1>
<Badge
variant="secondary"
className="border border-border bg-card text-foreground"
>
{mangaData.data?.status}
</Badge>
<div className="flex gap-4 items-center">
<Badge
variant="secondary"
className="border border-border bg-card text-foreground max-h-6"
>
{mangaData.data?.status}
</Badge>
{isAuthenticated && (
<>
<Button
size="icon"
variant="outline"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleFollowClick(
mangaData?.data?.following || false,
);
}}
disabled={isPendingFollowChange}
>
{mangaData?.data?.following ? <BellOff /> : <Bell />}
</Button>
<Button
size="icon"
variant="outline"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleFavoriteClick(
mangaData?.data?.favorite || false,
);
}}
disabled={isPendingFavoriteChange}
>
<Heart
className={`h-4 w-4 transition-colors ${
mangaData?.data?.favorite
? "fill-red-500 text-red-500"
: "text-muted-foreground hover:text-red-500"
}`}
/>
</Button>
</>
)}
</div>
</div>
<p className="text-lg text-muted-foreground">
{mangaData.data?.authors.join(", ")}