diff --git a/src/components/mangaCard.tsx b/src/components/mangaCard.tsx index 537c728..e0870cc 100644 --- a/src/components/mangaCard.tsx +++ b/src/components/mangaCard.tsx @@ -1,23 +1,10 @@ -import { - ActionIcon, - Alert, - Badge, - Box, - Button, - Checkbox, - Code, - createStyles, - Paper, - Skeleton, - Text, - Title, -} from '@mantine/core'; -import { useModals } from '@mantine/modals'; +import { ActionIcon, Badge, createStyles, Paper, Skeleton, Title, Tooltip } from '@mantine/core'; import { Prisma } from '@prisma/client'; -import { IconEdit, IconX } from '@tabler/icons'; +import { IconEdit, IconRefresh, IconX } from '@tabler/icons'; import { contrastColor } from 'contrast-color'; -import { useState } from 'react'; import stc from 'string-to-color'; +import { useRefreshModal } from './refreshMetadata'; +import { useRemoveModal } from './removeManga'; import { useUpdateModal } from './updateManga'; const useStyles = createStyles((theme, _params, getRef) => ({ @@ -49,6 +36,9 @@ const useStyles = createStyles((theme, _params, getRef) => ({ [`&:hover .${getRef('editButton')}`]: { display: 'flex', }, + [`&:hover .${getRef('refreshButton')}`]: { + display: 'flex', + }, }, removeButton: { ref: getRef('removeButton'), @@ -57,6 +47,18 @@ const useStyles = createStyles((theme, _params, getRef) => ({ top: -5, display: 'none', }, + refreshButton: { + ref: getRef('refreshButton'), + backgroundColor: theme.white, + color: theme.colors.blue[6], + position: 'absolute', + right: 10, + bottom: 50, + display: 'none', + '&:hover': { + backgroundColor: theme.colors.gray[0], + }, + }, editButton: { ref: getRef('editButton'), backgroundColor: theme.white, @@ -93,92 +95,21 @@ interface MangaCardProps { manga: MangaWithLibraryAndMetadata; onRemove: (shouldRemoveFiles: boolean) => void; onUpdate: () => void; + onRefresh: () => void; onClick: () => void; } -function RemoveModalContent({ - title, - onRemove, - onClose, -}: { - title: string; - onRemove: (shouldRemoveFiles: boolean) => void; - onClose: () => void; -}) { - const [shouldRemoveFiles, setShouldRemoveFiles] = useState(false); - return ( - <> - - Are you sure you want to remove - - {title} - - ? - - setShouldRemoveFiles(event.currentTarget.checked)} - /> - } - title="Remove files?" - color="red" - > - This action is destructive and all downloaded files will be removed - - ({ - display: 'flex', - gap: theme.spacing.xs, - justifyContent: 'end', - marginTop: theme.spacing.md, - })} - > - - - - - ); -} - -const useRemoveModal = (title: string, onRemove: (shouldRemoveFiles: boolean) => void) => { - const modals = useModals(); - - const openRemoveModal = () => { - const id = modals.openModal({ - title: `Remove ${title}?`, - centered: true, - children: modals.closeModal(id)} />, - }); - }; - - return openRemoveModal; -}; - export function SkeletonMangaCard() { const { classes } = useStyles(); return ; } -export function MangaCard({ manga, onRemove, onUpdate, onClick }: MangaCardProps) { +export function MangaCard({ manga, onRemove, onUpdate, onRefresh, onClick }: MangaCardProps) { const { classes } = useStyles(); const removeModal = useRemoveModal(manga.title, onRemove); + const refreshModal = useRefreshModal(manga.title, onRefresh); const updateModal = useUpdateModal(manga, onUpdate); - return ( - ) => { - e.stopPropagation(); - updateModal(); - }} - > - - + + ) => { + e.stopPropagation(); + refreshModal(); + }} + > + + + + + ) => { + e.stopPropagation(); + updateModal(); + }} + > + + +
void; onClose: () => void }) { + return ( + <> + + This will update all downloaded chapters with the latest metadata from AniList + + ({ + display: 'flex', + gap: theme.spacing.xs, + justifyContent: 'end', + marginTop: theme.spacing.md, + })} + > + + + + + ); +} + +export const useRefreshModal = (title: string, onRefresh: () => void) => { + const modals = useModals(); + + const openRemoveModal = () => { + const id = modals.openModal({ + title: `Refresh Metadata for ${title}?`, + centered: true, + children: modals.closeModal(id)} />, + }); + }; + + return openRemoveModal; +}; diff --git a/src/components/removeManga.tsx b/src/components/removeManga.tsx new file mode 100644 index 0000000..39107d6 --- /dev/null +++ b/src/components/removeManga.tsx @@ -0,0 +1,75 @@ +import { Alert, Box, Button, Checkbox, Code, Text } from '@mantine/core'; +import { useModals } from '@mantine/modals'; +import { useState } from 'react'; + +function RemoveModalContent({ + title, + onRemove, + onClose, +}: { + title: string; + onRemove: (shouldRemoveFiles: boolean) => void; + onClose: () => void; +}) { + const [shouldRemoveFiles, setShouldRemoveFiles] = useState(false); + return ( + <> + + Are you sure you want to remove + + {title} + + ? + + setShouldRemoveFiles(event.currentTarget.checked)} + /> + } + title="Remove files?" + color="red" + > + This action is destructive and all downloaded files will be removed + + ({ + display: 'flex', + gap: theme.spacing.xs, + justifyContent: 'end', + marginTop: theme.spacing.md, + })} + > + + + + + ); +} + +export const useRemoveModal = (title: string, onRemove: (shouldRemoveFiles: boolean) => void) => { + const modals = useModals(); + + const openRemoveModal = () => { + const id = modals.openModal({ + title: `Remove ${title}?`, + centered: true, + children: modals.closeModal(id)} />, + }); + }; + + return openRemoveModal; +}; diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 4f5fa3a..9f28b17 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -11,6 +11,7 @@ import { trpc } from '../utils/trpc'; export default function IndexPage() { const libraryQuery = trpc.library.query.useQuery(); const mangaRemove = trpc.manga.remove.useMutation(); + const mangaRefresh = trpc.manga.refreshMetaData.useMutation(); const router = useRouter(); const mangaQuery = trpc.manga.query.useQuery(); @@ -79,6 +80,38 @@ export default function IndexPage() { mangaQuery.refetch(); }; + const handleRefresh = async (id: number, title: string) => { + try { + await mangaRefresh.mutateAsync({ + id, + }); + showNotification({ + icon: , + color: 'teal', + autoClose: true, + title: 'Manga', + message: ( + + {title} chapters are queued for the metadata update + + ), + }); + } catch (err) { + showNotification({ + icon: , + color: 'red', + autoClose: true, + title: 'Manga', + message: ( + + {`${err}`} + + ), + }); + } + mangaQuery.refetch(); + }; + return ( @@ -91,6 +124,7 @@ export default function IndexPage() { handleRefresh(manga.id, manga.title)} onUpdate={() => mangaQuery.refetch()} onRemove={(shouldRemoveFiles: boolean) => handleRemove(manga.id, manga.title, shouldRemoveFiles)} onClick={() => router.push(`/manga/${manga.id}`)} diff --git a/src/server/trpc/router/manga.ts b/src/server/trpc/router/manga.ts index c84b72b..5d2bb91 100644 --- a/src/server/trpc/router/manga.ts +++ b/src/server/trpc/router/manga.ts @@ -6,7 +6,14 @@ import { checkChaptersQueue, removeJob, schedule } from '../../queue/checkChapte import { downloadQueue, downloadWorker, removeDownloadJobs } from '../../queue/download'; import { scheduleUpdateMetadata } from '../../queue/updateMetadata'; import { scanLibrary } from '../../utils/integration'; -import { bindTitleToAnilistId, getAvailableSources, getMangaDetail, removeManga, search } from '../../utils/mangal'; +import { + bindTitleToAnilistId, + getAvailableSources, + getMangaDetail, + getMangaMetadata, + removeManga, + search, +} from '../../utils/mangal'; import { t } from '../trpc'; export const mangaRouter = t.router({ @@ -312,4 +319,49 @@ export const mangaRouter = t.router({ completed: await downloadQueue.getCompletedCount(), }; }), + refreshMetaData: t.procedure + .input( + z.object({ + id: z.number(), + }), + ) + .mutation(async ({ input, ctx }) => { + const { id } = input; + const mangaInDb = await ctx.prisma.manga.findUniqueOrThrow({ + include: { library: true }, + where: { id }, + }); + const metadata = await getMangaMetadata(mangaInDb.source, mangaInDb.title); + if (!metadata) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: `Cannot find the metadata for ${mangaInDb.title}.`, + }); + } + await ctx.prisma.metadata.update({ + where: { + id: mangaInDb.metadataId, + }, + data: { + cover: metadata.cover?.extraLarge || metadata.cover?.large || metadata.cover?.medium, + authors: metadata.staff?.story ? [...metadata.staff.story] : [], + characters: metadata.characters, + genres: metadata.genres, + startDate: metadata.startDate + ? new Date(metadata.startDate.year, metadata.startDate.month, metadata.startDate.day) + : undefined, + endDate: metadata.endDate + ? new Date(metadata.endDate.year, metadata.endDate.month, metadata.endDate.day) + : undefined, + status: metadata.status, + summary: metadata.summary, + synonyms: metadata.synonyms, + tags: metadata.tags, + urls: metadata.urls, + }, + }); + await scheduleUpdateMetadata(mangaInDb.library.path, mangaInDb.title); + + return ctx.prisma.manga.findUniqueOrThrow({ include: { metadata: true, library: true }, where: { id } }); + }), }); diff --git a/src/server/utils/mangal.ts b/src/server/utils/mangal.ts index df87dcb..79d8394 100644 --- a/src/server/utils/mangal.ts +++ b/src/server/utils/mangal.ts @@ -219,6 +219,31 @@ export const getChaptersFromRemote = async (source: string, title: string): Prom return []; }; +export const getMangaMetadata = async (source: string, title: string): Promise => { + try { + const { stdout, escapedCommand } = await execa('mangal', [ + 'inline', + '--source', + source, + '--include-anilist-manga', + '--query', + title, + '--manga', + 'exact', + '-j', + ]); + logger.info(`Get manga metadata with following command: ${escapedCommand}`); + const output: IOutput = JSON.parse(stdout); + if (output && output.result.length === 1) { + return output.result[0].mangal?.metadata; + } + } catch (err) { + logger.error(err); + } + + return undefined; +}; + export const getMangaDetail = async (source: string, title: string): Promise => { try { const { stdout, escapedCommand } = await execa('mangal', [