diff --git a/.eslintrc.json b/.eslintrc.json
index fa75147..80045cf 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -15,6 +15,7 @@
"plugin:react/jsx-runtime"
],
"rules": {
+ "no-nested-ternary": "off",
"react/jsx-props-no-spreading": "off",
"no-underscore-dangle": "off",
"import/extensions": [
diff --git a/package.json b/package.json
index 009123d..0635e49 100644
--- a/package.json
+++ b/package.json
@@ -44,6 +44,7 @@
"@mantine/nprogress": "^5.5.4",
"@mantine/spotlight": "^5.5.4",
"@prisma/client": "4.4.0",
+ "@tabler/icons": "^1.101.0",
"@tanstack/react-query": "^4.9.0",
"@trpc/client": "^10.0.0-proxy-beta.13",
"@trpc/next": "^10.0.0-proxy-beta.13",
diff --git a/public/cover-not-found.jpg b/public/cover-not-found.jpg
new file mode 100644
index 0000000..378da68
Binary files /dev/null and b/public/cover-not-found.jpg differ
diff --git a/src/components/addLibrary.tsx b/src/components/addLibrary.tsx
index 73b6a4d..bb15a14 100644
--- a/src/components/addLibrary.tsx
+++ b/src/components/addLibrary.tsx
@@ -2,9 +2,9 @@ import { Box, Button, Code, LoadingOverlay, Text, TextInput } from '@mantine/cor
import { useForm } from '@mantine/form';
import { useModals } from '@mantine/modals';
import { showNotification } from '@mantine/notifications';
+import { IconCheck, IconX } from '@tabler/icons';
import { useState } from 'react';
-import { IoCloseCircle } from 'react-icons/io5';
-import { MdCheckCircleOutline, MdOutlineCreateNewFolder } from 'react-icons/md';
+import { MdOutlineCreateNewFolder } from 'react-icons/md';
import { trpc } from '../utils/trpc';
function Form({ onClose }: { onClose: () => void }) {
@@ -35,7 +35,7 @@ function Form({ onClose }: { onClose: () => void }) {
});
} catch (err) {
showNotification({
- icon: ,
+ icon: ,
color: 'red',
autoClose: true,
title: 'Library',
@@ -55,7 +55,7 @@ function Form({ onClose }: { onClose: () => void }) {
onClose();
setVisible((v) => !v);
showNotification({
- icon: ,
+ icon: ,
color: 'teal',
autoClose: true,
title: 'Library',
diff --git a/src/components/mangaCard.tsx b/src/components/mangaCard.tsx
index b12fa0e..160cca7 100644
--- a/src/components/mangaCard.tsx
+++ b/src/components/mangaCard.tsx
@@ -1,4 +1,5 @@
import { Badge, Button, createStyles, Paper, Title } from '@mantine/core';
+import { IconExternalLink } from '@tabler/icons';
const useStyles = createStyles((theme) => ({
card: {
@@ -35,7 +36,7 @@ const useStyles = createStyles((theme) => ({
interface ArticleCardImageProps {
image: string;
title: string;
- category: string;
+ category?: string;
}
export function MangaCard({ image, title, category }: ArticleCardImageProps) {
@@ -50,16 +51,22 @@ export function MangaCard({ image, title, category }: ArticleCardImageProps) {
className={classes.card}
>
-
- {category}
-
+ {category && (
+
+ {category}
+
+ )}
{title}
-
);
}
+
+MangaCard.defaultProps = {
+ category: '',
+};
diff --git a/src/components/mangaSearchResult.tsx b/src/components/mangaSearchResult.tsx
new file mode 100644
index 0000000..918c3b6
--- /dev/null
+++ b/src/components/mangaSearchResult.tsx
@@ -0,0 +1,142 @@
+import { createStyles, Image, SimpleGrid, Text, UnstyledButton } from '@mantine/core';
+import { useUncontrolled } from '@mantine/hooks';
+import { useState } from 'react';
+
+const useStyles = createStyles((theme, { checked, disabled }: { checked: boolean; disabled: boolean }) => ({
+ button: {
+ display: 'flex',
+ alignItems: 'center',
+ width: '100%',
+ transition: 'background-color 150ms ease, border-color 150ms ease',
+ border: `1px solid ${
+ checked
+ ? theme.fn.variant({ variant: 'outline', color: theme.primaryColor }).border
+ : theme.colorScheme === 'dark'
+ ? theme.colors.dark[8]
+ : theme.colors.gray[3]
+ }`,
+ borderRadius: theme.radius.sm,
+ padding: theme.spacing.sm,
+ backgroundColor: checked
+ ? theme.fn.variant({ variant: 'light', color: theme.primaryColor }).background
+ : disabled
+ ? theme.colors.gray[3]
+ : theme.white,
+ },
+
+ body: {
+ flex: 1,
+ marginLeft: theme.spacing.md,
+ },
+}));
+
+interface ImageCheckboxProps {
+ checked?: boolean;
+ defaultChecked?: boolean;
+ onChange?(checked: boolean): void;
+ title: string;
+ description: string;
+ image: string;
+}
+
+export function ImageCheckbox({
+ checked,
+ defaultChecked,
+ onChange,
+ title,
+ description,
+ className,
+ disabled,
+ image,
+ ...others
+}: ImageCheckboxProps & Omit, keyof ImageCheckboxProps>) {
+ const [value, handleChange] = useUncontrolled({
+ value: checked,
+ defaultValue: defaultChecked,
+ finalValue: false,
+ onChange,
+ });
+
+ const { classes, cx } = useStyles({ checked: value, disabled: disabled || false });
+
+ return (
+ {
+ if (!disabled) {
+ handleChange(!value);
+ }
+ }}
+ className={cx(classes.button, className)}
+ >
+ }
+ src={image}
+ width={42}
+ height={64}
+ />
+
+
+
+ {description}
+
+
+ {title}
+
+
+
+ );
+}
+
+ImageCheckbox.defaultProps = {
+ checked: undefined,
+ defaultChecked: undefined,
+ onChange: () => {},
+};
+
+type IMangaSearchResult = {
+ status: string;
+ title: string;
+ order: number;
+ cover: string;
+};
+
+export function MangaSearchResult({
+ items,
+ onSelect,
+}: {
+ items: IMangaSearchResult[];
+ onSelect: (selected: IMangaSearchResult | undefined) => void;
+}) {
+ const [selected, setSelected] = useState();
+
+ return (
+
+ {items.map((m) => (
+ {
+ if (checked) {
+ setSelected(m);
+ onSelect(m);
+ } else {
+ setSelected(undefined);
+ onSelect(undefined);
+ }
+ }}
+ />
+ ))}
+
+ );
+}
diff --git a/src/components/newMangaCard.tsx b/src/components/newMangaCard.tsx
index 159f492..443f9df 100644
--- a/src/components/newMangaCard.tsx
+++ b/src/components/newMangaCard.tsx
@@ -1,4 +1,32 @@
-import { Button, createStyles, Paper } from '@mantine/core';
+import {
+ ActionIcon,
+ Badge,
+ Box,
+ Button,
+ Code,
+ createStyles,
+ Divider,
+ Grid,
+ Group,
+ Image,
+ LoadingOverlay,
+ Paper,
+ Select,
+ Stepper,
+ Text,
+ TextInput,
+ Title,
+ Tooltip,
+} from '@mantine/core';
+import { useForm, UseFormReturnType, zodResolver } from '@mantine/form';
+import { getHotkeyHandler } from '@mantine/hooks';
+import { useModals } from '@mantine/modals';
+import { showNotification } from '@mantine/notifications';
+import { IconArrowRight, IconBook, IconCheck, IconSearch, IconX } from '@tabler/icons';
+import { useState } from 'react';
+import { z } from 'zod';
+import { trpc } from '../utils/trpc';
+import { MangaSearchResult } from './mangaSearchResult';
const useStyles = createStyles((theme) => ({
card: {
@@ -18,11 +46,414 @@ const useStyles = createStyles((theme) => ({
boxShadow: theme.shadows.md,
},
},
+ form: {
+ display: 'flex',
+ flexDirection: 'column',
+ minHeight: 300,
+ padding: theme.spacing.xs,
+ },
+ stepper: {
+ flexGrow: 1,
+ },
+ stepBody: {
+ marginTop: 30,
+ marginBottom: 30,
+ },
+ buttonGroup: {
+ position: 'fixed',
+ bottom: '19px',
+ right: '55px',
+ width: 'calc(100% - 55px)',
+ height: '50px',
+ background: 'white',
+ },
+ placeHolder: {
+ alignItems: 'start !important',
+ justifyContent: 'flex-start !important',
+ },
}));
-export function NewMangaCard() {
+const schema = z.object({
+ source: z.string().min(1, { message: 'You must select a source' }),
+ query: z.string().min(1, { message: 'Cannot be empty' }),
+ mangaOrder: z.number().gte(0, { message: 'Please select a manga' }),
+ mangaTitle: z.string().min(1, { message: 'Please select a manga' }),
+});
+
+type FormType = z.TypeOf;
+
+function SourceStep({ form }: { form: UseFormReturnType }) {
+ const query = trpc.manga.sources.useQuery(undefined, {
+ staleTime: Infinity,
+ });
+
+ if (query.isLoading) {
+ return ;
+ }
+
+ const selectData = query.data?.map((s) => ({
+ value: s,
+ label: s,
+ }));
+
+ return (
+
+
+
+ );
+}
+
+function SearchStep({ form }: { form: UseFormReturnType }) {
+ const ctx = trpc.useContext();
+ type SearchResult = Awaited>;
+
+ const [loading, setLoading] = useState(false);
+ const [searchResult, setSearchResult] = useState([]);
+
+ const handleSearch = async () => {
+ form.validateField('query');
+ if (!form.isValid('query')) {
+ return;
+ }
+ setLoading(true);
+ const result = await ctx.manga.search.fetch({
+ keyword: form.values.query,
+ source: form.values.source,
+ });
+ setLoading(false);
+
+ if (result) {
+ setSearchResult(result);
+ }
+ };
+
+ return (
+ <>
+
+
+ }
+ rightSection={
+
+
+
+ }
+ rightSectionWidth={42}
+ label="Search for a manga"
+ placeholder="Bleach"
+ {...form.getInputProps('query')}
+ />
+
+ {
+ if (selected) {
+ form.setFieldValue('mangaOrder', selected.order);
+ form.setFieldValue('mangaTitle', selected.title);
+ } else {
+ form.setFieldValue('mangaOrder', -1);
+ form.setFieldValue('mangaTitle', '');
+ }
+ }}
+ />
+ >
+ );
+}
+
+function ReviewStep({ form }: { form: UseFormReturnType }) {
+ const query = trpc.manga.detail.useQuery(
+ {
+ keyword: form.values.query,
+ source: form.values.source,
+ order: form.values.mangaOrder,
+ },
+ {
+ staleTime: Infinity,
+ enabled: !!form.values.query && !!form.values.source && !!form.values.mangaTitle && form.values.mangaOrder > -1,
+ },
+ );
+
const { classes } = useStyles();
+ const manga = query.data;
+
+ return (
+ <>
+
+
+ {manga && (
+
+
+ ({
+ boxShadow: theme.shadows.xl,
+ })}
+ withPlaceholder
+ placeholder={
+ ({
+ width: 210,
+ boxShadow: theme.shadows.xl,
+ })}
+ src="/cover-not-found.jpg"
+ alt={manga.Name}
+ />
+ }
+ src={manga.Metadata.Cover}
+ />
+
+
+ {manga.Name}} />
+ {manga.Metadata.Synonyms && (
+
+ {manga.Metadata.Synonyms.map((synonym) => (
+
+
+
+ {synonym}
+
+
+
+ ))}
+
+ )}
+
+ {manga.Metadata.Status ? (
+
+ {manga.Metadata.Status}
+
+ ) : (
+ No status...
+ )}
+
+
+ There are
+
+ {manga.Chapters?.length || 0}
+
+ chapters
+
+
+ {manga.Metadata.Summary || 'No summary...'}
+
+ {manga.Metadata.Genres ? (
+
+ {manga.Metadata.Genres.map((genre) => (
+
+
+
+ {genre}
+
+
+
+ ))}
+
+ ) : (
+ No genres...
+ )}
+
+ {manga.Metadata.Tags ? (
+
+ {manga.Metadata.Tags.map((tag) => (
+
+
+
+ {tag}
+
+
+
+ ))}
+
+ ) : (
+ No tags...
+ )}
+
+
+ )}
+ >
+ );
+}
+
+function Form({ onClose }: { onClose: () => void }) {
+ const { classes } = useStyles();
+ const [active, setActive] = useState(0);
+ const [visible, setVisible] = useState(false);
+
+ const mutation = trpc.manga.add.useMutation();
+
+ const form = useForm({
+ validateInputOnBlur: ['source', 'query'],
+ initialValues: {
+ source: '',
+ query: '',
+ mangaOrder: -1,
+ mangaTitle: '',
+ },
+ validate: zodResolver(schema),
+ });
+
+ const nextStep = () => {
+ if (active === 0) {
+ form.validateField('source');
+ if (!form.isValid('source')) {
+ return;
+ }
+ }
+ if (active === 1) {
+ form.validateField('mangaTitle');
+ form.validateField('mangaOrder');
+ if (!form.isValid('mangaOrder') || !form.isValid('mangaTitle')) {
+ return;
+ }
+ }
+ form.clearErrors();
+ setActive((current) => (current < 2 ? current + 1 : current));
+ };
+
+ const prevStep = () => {
+ if (active === 1) {
+ form.setFieldValue('query', '');
+ form.setFieldValue('source', '');
+ }
+ if (active === 2) {
+ form.setFieldValue('query', '');
+ form.setFieldValue('mangaOrder', -1);
+ form.setFieldValue('mangaTitle', '');
+ }
+ setActive((current) => (current > 0 ? current - 1 : current));
+ };
+ return (
+
+ );
+}
+
+export function NewMangaCard({ onAdd }: { onAdd: () => void }) {
+ const { classes } = useStyles();
+ const modals = useModals();
+
+ const openCreateModal = () => {
+ const id = modals.openModal({
+ overflow: 'inside',
+ trapFocus: true,
+ size: 'xl',
+ title: 'Add a new manga',
+ centered: true,
+ children: (
+