implemented ratings, fixes
This commit is contained in:
parent
4801cf3a13
commit
d1da980501
31 changed files with 775 additions and 236 deletions
|
|
@ -4,7 +4,8 @@ import { BookChapterDeleteMutation } from '@generated/types';
|
||||||
import { Link } from '@tanstack/react-router';
|
import { Link } from '@tanstack/react-router';
|
||||||
import { bookFragment } from '../fragments';
|
import { bookFragment } from '../fragments';
|
||||||
import { useToastMutation } from '../hooks/useToastMutation';
|
import { useToastMutation } from '../hooks/useToastMutation';
|
||||||
import { bookChapterListQuery } from '../pages/work/book/$bookId/overview';
|
import { bookChapterQuery } from '../pages/work/book/$bookId/chapter/$chapterId/view';
|
||||||
|
import { bookChapterListQuery } from '../pages/work/book/$bookId/overview/contents';
|
||||||
|
|
||||||
export function BookChapterCard({
|
export function BookChapterCard({
|
||||||
bookId,
|
bookId,
|
||||||
|
|
@ -20,7 +21,7 @@ export function BookChapterCard({
|
||||||
const { update: chapterDelete } = useToastMutation<BookChapterDeleteMutation>({
|
const { update: chapterDelete } = useToastMutation<BookChapterDeleteMutation>({
|
||||||
mutation,
|
mutation,
|
||||||
successMsg: 'Chapter deleted successfully',
|
successMsg: 'Chapter deleted successfully',
|
||||||
refetchQueries: [bookChapterListQuery]
|
refetchQueries: [bookChapterListQuery, bookChapterQuery]
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<Card.Root width="100%" variant="elevated">
|
<Card.Root width="100%" variant="elevated">
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ export function BookChapterForm({
|
||||||
</Box>
|
</Box>
|
||||||
<Stack direction="row">
|
<Stack direction="row">
|
||||||
<Button onClick={async () => onSubmit()}>Submit</Button>
|
<Button onClick={async () => onSubmit()}>Submit</Button>
|
||||||
<Link to={`/work/book/${bookId}/overview`}>
|
<Link to={`/work/book/${bookId}/overview/contents`}>
|
||||||
<Button>Go back</Button>
|
<Button>Go back</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
|
||||||
23
app/client/components/BookOverviewTabs.tsx
Normal file
23
app/client/components/BookOverviewTabs.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { Tabs } from '@chakra-ui/react';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
|
||||||
|
export function BookOverviewTabs({
|
||||||
|
bookId,
|
||||||
|
currentTab
|
||||||
|
}: {
|
||||||
|
readonly bookId: string;
|
||||||
|
readonly currentTab: 'contents' | 'reviews';
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Tabs.Root defaultValue="members" value={currentTab}>
|
||||||
|
<Tabs.List>
|
||||||
|
<Link to={`/work/book/${bookId}/overview/contents`}>
|
||||||
|
<Tabs.Trigger value="contents">Contents</Tabs.Trigger>
|
||||||
|
</Link>
|
||||||
|
<Link to={`/work/book/${bookId}/overview/reviews`}>
|
||||||
|
<Tabs.Trigger value="reviews">Reviews</Tabs.Trigger>
|
||||||
|
</Link>
|
||||||
|
</Tabs.List>
|
||||||
|
</Tabs.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
app/client/components/RatingPanel.tsx
Normal file
48
app/client/components/RatingPanel.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { Button } from '@app/client/components/ui/button';
|
||||||
|
import { Icon, Stack } from '@chakra-ui/react';
|
||||||
|
import { RatingType } from '@generated/types';
|
||||||
|
|
||||||
|
export function RatingPanel({
|
||||||
|
personalRating,
|
||||||
|
onRatingSet
|
||||||
|
}: {
|
||||||
|
readonly personalRating: RatingType;
|
||||||
|
readonly onRatingSet: (rating: RatingType) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Stack direction="row">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
backgroundColor={personalRating === RatingType.Up ? 'green.500/80' : 'green.500'}
|
||||||
|
borderColor="green.500"
|
||||||
|
onClick={async () => {
|
||||||
|
if (personalRating !== RatingType.Up) {
|
||||||
|
await onRatingSet(RatingType.Up);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onRatingSet(RatingType.None);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon>
|
||||||
|
<i className="nf nf-oct-thumbsup" />
|
||||||
|
</Icon>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
backgroundColor={personalRating === RatingType.Down ? 'red.500/80' : 'red.500'}
|
||||||
|
borderColor="red.500"
|
||||||
|
onClick={async () => {
|
||||||
|
if (personalRating !== RatingType.Down) {
|
||||||
|
await onRatingSet(RatingType.Down);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onRatingSet(RatingType.None);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon>
|
||||||
|
<i className="nf nf-oct-thumbsdown" />
|
||||||
|
</Icon>
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
app/client/components/ReviewCard.tsx
Normal file
54
app/client/components/ReviewCard.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { Avatar } from '@app/client/components/ui/avatar';
|
||||||
|
import { toFormattedDate } from '@app/client/helpers';
|
||||||
|
import { Box, Card, Stack, Text } from '@chakra-ui/react';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
|
||||||
|
export function ReviewCard({
|
||||||
|
review
|
||||||
|
}: {
|
||||||
|
readonly review: {
|
||||||
|
readonly id: string;
|
||||||
|
readonly username: string;
|
||||||
|
readonly createdAt: string;
|
||||||
|
readonly text: string;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Stack direction="row" alignItems="start">
|
||||||
|
<Link to={`/profile/${review.username}`}>
|
||||||
|
<Avatar size="sm" fallback={review.username[0]} />
|
||||||
|
</Link>
|
||||||
|
<Card.Root variant="subtle" width="100%">
|
||||||
|
<Card.Body paddingX="3" paddingY="1">
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="center" gap={1}>
|
||||||
|
<Link to={`/profile/${review.username}`}>
|
||||||
|
<Text
|
||||||
|
color="colorPalette.400"
|
||||||
|
_hover={{
|
||||||
|
textDecoration: 'underline',
|
||||||
|
textDecorationColor: 'colorPalette.700'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{review.username}
|
||||||
|
</Text>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Text color="fg.muted">{toFormattedDate({ value: review.createdAt })}</Text>
|
||||||
|
</Stack>
|
||||||
|
<Box width="100%" position="relative" padding={0}>
|
||||||
|
<Text>{review.text}</Text>
|
||||||
|
{/* <Button */}
|
||||||
|
{/* size="sm" */}
|
||||||
|
{/* position="absolute" */}
|
||||||
|
{/* right="2px" */}
|
||||||
|
{/* bottom="5px" */}
|
||||||
|
{/* padding={0} */}
|
||||||
|
{/* variant="ghost" */}
|
||||||
|
{/* // onClick={async () => onReviewSubmit(text)} */}
|
||||||
|
{/* ></Button> */}
|
||||||
|
</Box>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
app/client/components/ReviewInput.tsx
Normal file
47
app/client/components/ReviewInput.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { Avatar } from '@app/client/components/ui/avatar';
|
||||||
|
import { Button, Icon, Stack, Textarea } from '@chakra-ui/react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export function ReviewInput({
|
||||||
|
username,
|
||||||
|
onReviewSubmit
|
||||||
|
}: {
|
||||||
|
readonly username: string;
|
||||||
|
readonly onReviewSubmit: (text: string) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [text, setText] = useState<string>('');
|
||||||
|
return (
|
||||||
|
<Stack direction="row" alignItems="start">
|
||||||
|
<Avatar size="sm" fallback={username[0]} />
|
||||||
|
<Stack width="100%" position="relative" borderRadius="md">
|
||||||
|
<Textarea
|
||||||
|
variant="subtle"
|
||||||
|
placeholder="Write your review here..."
|
||||||
|
resize="none"
|
||||||
|
maxLength={700}
|
||||||
|
maxLines={10}
|
||||||
|
fontSize="md"
|
||||||
|
autoresize
|
||||||
|
value={text}
|
||||||
|
onChange={event => setText(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
position="absolute"
|
||||||
|
right="2px"
|
||||||
|
bottom="5px"
|
||||||
|
padding={0}
|
||||||
|
variant="ghost"
|
||||||
|
onClick={async () => {
|
||||||
|
await onReviewSubmit(text);
|
||||||
|
setText('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon>
|
||||||
|
<i className="nf nf-fa-send" />
|
||||||
|
</Icon>
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -22,9 +22,9 @@ export function WorkingListCard({
|
||||||
<Card.Root width="100%" variant="elevated">
|
<Card.Root width="100%" variant="elevated">
|
||||||
<Card.Body>
|
<Card.Body>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack direction="row" justifyContent="space-between">
|
<Stack direction={'row'} justifyContent="space-between">
|
||||||
<Text as="div" minWidth={0}>
|
<Text as="div" minWidth={0}>
|
||||||
<Link to={`/work/book/${id}/overview`}>
|
<Link to={`/work/book/${id}/overview/contents`}>
|
||||||
<Heading
|
<Heading
|
||||||
color="colorPalette.400"
|
color="colorPalette.400"
|
||||||
_hover={{
|
_hover={{
|
||||||
|
|
@ -37,7 +37,7 @@ export function WorkingListCard({
|
||||||
</Link>
|
</Link>
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Stack direction="row" justifyContent="center" alignItems="center">
|
<Stack direction="row" justifyContent="center" alignItems="center" hideBelow="md">
|
||||||
<WorkingRatingBadge ratingTotal={ratingTotal} />
|
<WorkingRatingBadge ratingTotal={ratingTotal} />
|
||||||
<WorkingReviewsBadge reviewsTotal={reviewsTotal} />
|
<WorkingReviewsBadge reviewsTotal={reviewsTotal} />
|
||||||
<WorkingProgressStatusBadge progressStatus={progressStatus} />
|
<WorkingProgressStatusBadge progressStatus={progressStatus} />
|
||||||
|
|
@ -60,7 +60,7 @@ export function WorkingListCard({
|
||||||
</Link>
|
</Link>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack direction="row">
|
<Stack maxWidth="100%" direction="row" overflow="auto" flexWrap="wrap">
|
||||||
<Box>
|
<Box>
|
||||||
<Badge size="md" variant="solid">
|
<Badge size="md" variant="solid">
|
||||||
<Stack direction="row">
|
<Stack direction="row">
|
||||||
|
|
@ -88,6 +88,12 @@ export function WorkingListCard({
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction="row" justifyContent="left" alignItems="center" hideFrom="md">
|
||||||
|
<WorkingRatingBadge ratingTotal={ratingTotal} />
|
||||||
|
<WorkingReviewsBadge reviewsTotal={reviewsTotal} />
|
||||||
|
<WorkingProgressStatusBadge progressStatus={progressStatus} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
<Stack>
|
<Stack>
|
||||||
<Text lineClamp={4}>{description}</Text>
|
<Text lineClamp={4}>{description}</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
|
||||||
|
|
@ -38,3 +38,16 @@ export const bookChapterFragment = gql`
|
||||||
reviewsTotal
|
reviewsTotal
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const reviewFragment = gql`
|
||||||
|
fragment Review on Review {
|
||||||
|
id
|
||||||
|
author {
|
||||||
|
id
|
||||||
|
username
|
||||||
|
}
|
||||||
|
text
|
||||||
|
ratingTotal
|
||||||
|
createdAt
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ const isProd = mode === 'prod';
|
||||||
|
|
||||||
export const appName = import.meta.env.VITE_APP_NAME || 'project-ln';
|
export const appName = import.meta.env.VITE_APP_NAME || 'project-ln';
|
||||||
|
|
||||||
const host = import.meta.env.VITE_HOST || 'localhost';
|
const host = import.meta.env.VITE_DOMAIN_NAME || import.meta.env.VITE_HOST || 'localhost';
|
||||||
const port = import.meta.env.VITE_PORT || '3000';
|
const port = import.meta.env.VITE_PORT || '3000';
|
||||||
export const url = `https://${host}:${port}`;
|
export const url = `https://${host}:${port}`;
|
||||||
const graphqlUri = `${url}/graphql`;
|
const graphqlUri = `${url}/graphql`;
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
||||||
|
|
||||||
<Stack flexDirection="row" alignItems="center" gap={4}>
|
<Stack flexDirection="row" alignItems="center" gap={4}>
|
||||||
<Stack
|
<Stack
|
||||||
|
hideBelow="md"
|
||||||
direction="row"
|
direction="row"
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
paddingY={1}
|
paddingY={1}
|
||||||
|
|
@ -124,7 +125,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
||||||
{identity != null ? (
|
{identity != null ? (
|
||||||
<Menu.Root>
|
<Menu.Root>
|
||||||
<Menu.Trigger css={{ '&:hover': { cursor: 'pointer' } }}>
|
<Menu.Trigger css={{ '&:hover': { cursor: 'pointer' } }}>
|
||||||
<Avatar size="sm" />
|
<Avatar size="sm" fallback={identity.data.user.username[0]} />
|
||||||
</Menu.Trigger>
|
</Menu.Trigger>
|
||||||
<Menu.Positioner mt={2}>
|
<Menu.Positioner mt={2}>
|
||||||
<Menu.Content backgroundColor={'bg.subtle'}>
|
<Menu.Content backgroundColor={'bg.subtle'}>
|
||||||
|
|
@ -169,7 +170,9 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
||||||
</Stack>
|
</Stack>
|
||||||
<Separator style={{ padding: '4px' }} />
|
<Separator style={{ padding: '4px' }} />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<TanStackRouterDevtools />
|
<Stack hidden>
|
||||||
|
<TanStackRouterDevtools />
|
||||||
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ function NewBookForm() {
|
||||||
});
|
});
|
||||||
if (data != null)
|
if (data != null)
|
||||||
navigate({
|
navigate({
|
||||||
to: `/work/book/${data.bookCreate.id}/overview`
|
to: `/work/book/${data.bookCreate.id}/overview/contents`
|
||||||
});
|
});
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ function RouteComponent() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
navigate({ to: `/work/book/${bookId}/overview` });
|
navigate({ to: `/work/book/${bookId}/overview/contents` });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import { gql, useQuery } from '@apollo/client';
|
import { gql, useQuery } from '@apollo/client';
|
||||||
import { EditorSlate } from '@app/client/components/EditorSlate';
|
import { EditorSlate } from '@app/client/components/EditorSlate';
|
||||||
import { bookChapterFragment } from '@app/client/fragments';
|
import { bookChapterFragment } from '@app/client/fragments';
|
||||||
import { Button, Heading, Icon, Skeleton, Stack, Text } from '@chakra-ui/react';
|
import { Button, Heading, Icon, Separator, Skeleton, Stack, Text } from '@chakra-ui/react';
|
||||||
import { BookChapterViewQuery, BookChapterViewNavigationQuery } from '@generated/types';
|
import { BookChapterViewNavigationQuery, BookChapterViewQuery } from '@generated/types';
|
||||||
import { createFileRoute, Link } from '@tanstack/react-router';
|
import { createFileRoute, Link } from '@tanstack/react-router';
|
||||||
import { Descendant } from 'slate';
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/work/book/$bookId/chapter/$chapterId/view')({
|
export const Route = createFileRoute('/work/book/$bookId/chapter/$chapterId/view')({
|
||||||
component: RouteComponent
|
component: RouteComponent
|
||||||
|
|
@ -13,53 +12,35 @@ export const Route = createFileRoute('/work/book/$bookId/chapter/$chapterId/view
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { bookId, chapterId } = Route.useParams();
|
const { bookId, chapterId } = Route.useParams();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack justifyContent="center" alignItems="center">
|
||||||
|
<BookChapterView bookId={bookId} chapterId={chapterId} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BookChapterView({
|
||||||
|
bookId,
|
||||||
|
chapterId
|
||||||
|
}: {
|
||||||
|
readonly bookId: string;
|
||||||
|
readonly chapterId: string;
|
||||||
|
}) {
|
||||||
const { data } = useQuery<BookChapterViewQuery>(bookChapterQuery, {
|
const { data } = useQuery<BookChapterViewQuery>(bookChapterQuery, {
|
||||||
variables: { id: chapterId }
|
variables: { id: chapterId },
|
||||||
|
fetchPolicy: 'cache-and-network'
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: navigationData } = useQuery<BookChapterViewNavigationQuery>(
|
const { data: navigationData } = useQuery<BookChapterViewNavigationQuery>(
|
||||||
bookChapterNavigationQuery,
|
bookChapterNavigationQuery,
|
||||||
{
|
{
|
||||||
variables: { id: chapterId }
|
variables: { id: chapterId },
|
||||||
|
fetchPolicy: 'no-cache'
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (data == null || navigationData == null) return <Skeleton />;
|
if (data == null) return <Skeleton />;
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack justifyContent="center" alignItems="center">
|
|
||||||
<BookChapterView
|
|
||||||
bookId={bookId}
|
|
||||||
chapter={{ ...data.bookChapterOne, ...navigationData.bookChapterOne }}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BookChapterViewChapter {
|
|
||||||
readonly id: string;
|
|
||||||
readonly title: string;
|
|
||||||
readonly createdAt: string;
|
|
||||||
readonly updatedAt: string;
|
|
||||||
readonly foreword?: string | null;
|
|
||||||
readonly footnote?: string | null;
|
|
||||||
readonly content: Descendant[];
|
|
||||||
readonly sequenceNumber: number;
|
|
||||||
readonly reviewsTotal: number;
|
|
||||||
readonly previousChapterId?: string | null;
|
|
||||||
readonly nextChapterId?: string | null;
|
|
||||||
readonly book: {
|
|
||||||
readonly title: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function BookChapterView({
|
|
||||||
bookId,
|
|
||||||
chapter
|
|
||||||
}: {
|
|
||||||
readonly bookId: string;
|
|
||||||
readonly chapter: BookChapterViewChapter;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
maxWidth="840px"
|
maxWidth="840px"
|
||||||
|
|
@ -70,18 +51,38 @@ function BookChapterView({
|
||||||
>
|
>
|
||||||
<Heading as="div" maxWidth="100%">
|
<Heading as="div" maxWidth="100%">
|
||||||
<Text textAlign="center" minWidth={0}>
|
<Text textAlign="center" minWidth={0}>
|
||||||
{chapter.book.title}
|
{data.bookChapterOne.book.title}
|
||||||
</Text>
|
</Text>
|
||||||
<Text textAlign="center">-</Text>
|
<Text textAlign="center">-</Text>
|
||||||
<Text textAlign="center" minWidth={0}>
|
<Text textAlign="center" minWidth={0}>
|
||||||
{chapter.title}
|
{data.bookChapterOne.title}
|
||||||
</Text>
|
</Text>
|
||||||
</Heading>
|
</Heading>
|
||||||
<EditorSlate initialValue={chapter.content} readOnly />
|
{data.bookChapterOne.foreword && (
|
||||||
|
<>
|
||||||
|
<Stack width="91%" justifyContent="left" alignItems="start" paddingY={2}>
|
||||||
|
<Text fontWeight="bold">Foreword:</Text>
|
||||||
|
<Text color="fg.muted">{data.bookChapterOne.foreword}</Text>
|
||||||
|
</Stack>
|
||||||
|
<Separator width="91%" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<EditorSlate initialValue={data.bookChapterOne.content} readOnly />
|
||||||
|
{data.bookChapterOne.footnote && (
|
||||||
|
<>
|
||||||
|
<Separator width="91%" />
|
||||||
|
<Stack width="91%" justifyContent="left" alignItems="start" paddingY={2}>
|
||||||
|
<Text fontWeight="bold">Footnote:</Text>
|
||||||
|
<Text color="fg.muted">{data.bookChapterOne.foreword}</Text>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text>{data.bookChapterOne.footnote}</Text>
|
||||||
<ChapterNavigation
|
<ChapterNavigation
|
||||||
bookId={bookId}
|
bookId={bookId}
|
||||||
previousChapterId={chapter.previousChapterId}
|
previousChapterId={navigationData?.bookChapterOne.previousChapterId}
|
||||||
nextChapterId={chapter.nextChapterId}
|
nextChapterId={navigationData?.bookChapterOne.nextChapterId}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|
@ -108,7 +109,7 @@ function ChapterNavigation({
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<Link to={`/work/book/${bookId}/overview`}>
|
<Link to={`/work/book/${bookId}/overview/contents`}>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
borderLeftRadius={previousChapterId ? 0 : undefined}
|
borderLeftRadius={previousChapterId ? 0 : undefined}
|
||||||
|
|
@ -134,7 +135,7 @@ function ChapterNavigation({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const bookChapterQuery = gql`
|
export const bookChapterQuery = gql`
|
||||||
query BookChapterView($id: ID!) {
|
query BookChapterView($id: ID!) {
|
||||||
bookChapterOne(id: $id) {
|
bookChapterOne(id: $id) {
|
||||||
id
|
id
|
||||||
|
|
@ -149,7 +150,7 @@ const bookChapterQuery = gql`
|
||||||
${bookChapterFragment}
|
${bookChapterFragment}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const bookChapterNavigationQuery = gql`
|
const bookChapterNavigationQuery = gql`
|
||||||
query BookChapterViewNavigation($id: ID!) {
|
query BookChapterViewNavigation($id: ID!) {
|
||||||
bookChapterOne(id: $id) {
|
bookChapterOne(id: $id) {
|
||||||
id
|
id
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,7 @@ import { useToastMutation } from '@app/client/hooks/useToastMutation';
|
||||||
import { Skeleton } from '@chakra-ui/react';
|
import { Skeleton } from '@chakra-ui/react';
|
||||||
import { BookChapterNewCreateMutation, BookChapterNewQuery } from '@generated/types';
|
import { BookChapterNewCreateMutation, BookChapterNewQuery } from '@generated/types';
|
||||||
import { createFileRoute, redirect, useNavigate } from '@tanstack/react-router';
|
import { createFileRoute, redirect, useNavigate } from '@tanstack/react-router';
|
||||||
import { bookChapterListQuery } from '../overview';
|
import { bookChapterListQuery } from '../overview/contents';
|
||||||
import { bookChapterNavigationQuery } from './$chapterId/view';
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/work/book/$bookId/chapter/new')({
|
export const Route = createFileRoute('/work/book/$bookId/chapter/new')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
|
|
@ -28,12 +27,12 @@ function RouteComponent() {
|
||||||
const { update: chapterCreate } = useToastMutation<BookChapterNewCreateMutation>({
|
const { update: chapterCreate } = useToastMutation<BookChapterNewCreateMutation>({
|
||||||
mutation,
|
mutation,
|
||||||
successMsg: 'Chapter created successfully',
|
successMsg: 'Chapter created successfully',
|
||||||
refetchQueries: [bookChapterListQuery, bookChapterNavigationQuery]
|
refetchQueries: [bookChapterListQuery]
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data == null) return <Skeleton />;
|
if (data == null) return <Skeleton />;
|
||||||
if (identity?.data.user.id !== data.bookOne.author.id)
|
if (identity?.data.user.id !== data.bookOne.author.id)
|
||||||
throw redirect({ to: `/work/book/${id}/overview` });
|
throw redirect({ to: `/work/book/${id}/overview/contents` });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BookChapterForm
|
<BookChapterForm
|
||||||
|
|
@ -48,7 +47,7 @@ function RouteComponent() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
navigate({ to: `/work/book/${id}/overview` });
|
navigate({ to: `/work/book/${id}/overview/contents` });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,13 @@
|
||||||
import { gql, useQuery } from '@apollo/client';
|
import { gql, useQuery } from '@apollo/client';
|
||||||
import { BookChapterCard } from '@app/client/components/BookChapterCard';
|
|
||||||
import { PaginatedListSkeleton } from '@app/client/components/PaginatedListSkeleton';
|
|
||||||
import { Pagination } from '@app/client/components/Pagination';
|
|
||||||
import { Button } from '@app/client/components/ui/button';
|
|
||||||
import { WorkingProgressStatusBadge } from '@app/client/components/WorkingProgressStatusBadge';
|
import { WorkingProgressStatusBadge } from '@app/client/components/WorkingProgressStatusBadge';
|
||||||
import { WorkingRatingBadge } from '@app/client/components/WorkingRatingBadge';
|
import { WorkingRatingBadge } from '@app/client/components/WorkingRatingBadge';
|
||||||
import { WorkingReviewsBadge } from '@app/client/components/WorkingReviewsTotalBadge';
|
import { WorkingReviewsBadge } from '@app/client/components/WorkingReviewsTotalBadge';
|
||||||
import { bookChapterFragment, bookFragment } from '@app/client/fragments';
|
import { bookFragment } from '@app/client/fragments';
|
||||||
import { toFormattedDate } from '@app/client/helpers';
|
import { toFormattedDate } from '@app/client/helpers';
|
||||||
import { IdentityContext, useIdentity } from '@app/client/hooks/identity';
|
|
||||||
import { usePaginatedQuery } from '@app/client/hooks/usePaginatedQuery';
|
|
||||||
import { BookOne } from '@app/client/interfaces';
|
import { BookOne } from '@app/client/interfaces';
|
||||||
import { Box, Heading, HStack, Icon, Skeleton, SkeletonText, Stack, Text } from '@chakra-ui/react';
|
import { Heading, HStack, Skeleton, SkeletonText, Stack, Text } from '@chakra-ui/react';
|
||||||
import { BookChapterListQuery, BookOverviewQuery } from '@generated/types';
|
import { BookOverviewQuery } from '@generated/types';
|
||||||
import { createFileRoute, Link } from '@tanstack/react-router';
|
import { createFileRoute, Link, Outlet } from '@tanstack/react-router';
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/work/book/$bookId/overview')({
|
export const Route = createFileRoute('/work/book/$bookId/overview')({
|
||||||
component: RouteComponent
|
component: RouteComponent
|
||||||
|
|
@ -26,28 +19,16 @@ function RouteComponent() {
|
||||||
variables: { id }
|
variables: { id }
|
||||||
});
|
});
|
||||||
|
|
||||||
const identity = useIdentity();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack width="100%" justifyContent="center" alignItems="center" padding={3}>
|
<Stack width="100%" justifyContent="center" alignItems="center" padding={3}>
|
||||||
{bookData != null ? (
|
{bookData != null ? <BookOverview book={bookData.bookOne} /> : <BookOverviewSkeleton />}
|
||||||
<BookOverview identity={identity} book={bookData.bookOne} />
|
|
||||||
) : (
|
|
||||||
<BookOverviewSkeleton />
|
|
||||||
)}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BookOverview({
|
function BookOverview({ book }: { readonly book: BookOne }) {
|
||||||
identity,
|
|
||||||
book
|
|
||||||
}: {
|
|
||||||
readonly identity: IdentityContext | null;
|
|
||||||
readonly book: BookOne;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<Stack width="80%" maxWidth="840px" gap={3}>
|
<Stack width={{ base: '100%', md: '80%' }} maxWidth="840px" gap={3}>
|
||||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||||
<Heading color="colorPalette.400" size="2xl" overflow="auto">
|
<Heading color="colorPalette.400" size="2xl" overflow="auto">
|
||||||
{book.title}
|
{book.title}
|
||||||
|
|
@ -101,119 +82,14 @@ function BookOverview({
|
||||||
|
|
||||||
<Text>{book.description}</Text>
|
<Text>{book.description}</Text>
|
||||||
|
|
||||||
<BookChapterList
|
<Outlet />
|
||||||
id={book.id}
|
|
||||||
identity={identity}
|
|
||||||
isAuthor={identity?.data.id === book.author.id}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BookChapterList({
|
|
||||||
id,
|
|
||||||
identity,
|
|
||||||
isAuthor
|
|
||||||
}: {
|
|
||||||
readonly id: string;
|
|
||||||
readonly identity: IdentityContext | null;
|
|
||||||
readonly isAuthor: boolean;
|
|
||||||
}) {
|
|
||||||
const [chaptersListPage, setChaptersListPage] = useState<number>(1);
|
|
||||||
const [chaptersListPageSize, setChaptersListPageSize] = useState<number>(10);
|
|
||||||
const startIndex = (chaptersListPage - 1) * chaptersListPageSize;
|
|
||||||
|
|
||||||
const { data } = usePaginatedQuery<BookChapterListQuery>({
|
|
||||||
query: bookChapterListQuery,
|
|
||||||
input: {
|
|
||||||
bookId: id,
|
|
||||||
filter: {
|
|
||||||
offset: startIndex,
|
|
||||||
size: chaptersListPageSize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack>
|
|
||||||
{data != null ? (
|
|
||||||
<>
|
|
||||||
<Stack>
|
|
||||||
{data.bookChapterMany.items.length > 0 ? (
|
|
||||||
<>
|
|
||||||
{data.bookChapterMany.items.map(item => (
|
|
||||||
<Stack key={`bookChapterList-${item.id}`}>
|
|
||||||
<BookChapterCard
|
|
||||||
bookId={id}
|
|
||||||
chapterId={item.id}
|
|
||||||
title={item.title}
|
|
||||||
isAuthor={isAuthor}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Stack justifyContent="center" alignItems="center" pt={8}>
|
|
||||||
<Text>No chapters here yet!</Text>
|
|
||||||
{isAuthor && (
|
|
||||||
<Link to={`/work/book/${id}/chapter/new`}>
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
Create new chapter
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
{data.bookChapterMany.total > chaptersListPageSize && (
|
|
||||||
<Stack justifyContent="center" alignItems="center">
|
|
||||||
<Pagination
|
|
||||||
total={data.bookChapterMany.total}
|
|
||||||
page={chaptersListPage}
|
|
||||||
setPage={setChaptersListPage}
|
|
||||||
pageSize={chaptersListPageSize}
|
|
||||||
setPageSize={setChaptersListPageSize}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Stack direction="row">
|
|
||||||
<>
|
|
||||||
{isAuthor && data.bookChapterMany.items.length !== 0 && (
|
|
||||||
<Box>
|
|
||||||
<Link to={`/work/book/${id}/chapter/new`}>
|
|
||||||
<Button size="sm">Create new chapter</Button>
|
|
||||||
</Link>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
|
|
||||||
{identity != null && !isAuthor && (
|
|
||||||
<>
|
|
||||||
<Button size="sm" backgroundColor="green.500" disabled>
|
|
||||||
<Icon>
|
|
||||||
<i className="nf nf-oct-thumbsup" />
|
|
||||||
</Icon>
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" backgroundColor="red.500">
|
|
||||||
<Icon>
|
|
||||||
<i className="nf nf-oct-thumbsdown" />
|
|
||||||
</Icon>
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<PaginatedListSkeleton disableSearch />
|
|
||||||
)}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BookOverviewSkeleton() {
|
function BookOverviewSkeleton() {
|
||||||
return (
|
return (
|
||||||
<Stack width="80%" maxWidth="840px">
|
<Stack width={{ base: '100%', md: '80%' }} maxWidth="840px">
|
||||||
<Stack gap="6" maxWidth="100%">
|
<Stack gap="6" maxWidth="100%">
|
||||||
<HStack width="full">
|
<HStack width="full">
|
||||||
<SkeletonText noOfLines={5} />
|
<SkeletonText noOfLines={5} />
|
||||||
|
|
@ -224,7 +100,7 @@ function BookOverviewSkeleton() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const bookQuery = gql`
|
export const bookQuery = gql`
|
||||||
query BookOverview($id: ID!) {
|
query BookOverview($id: ID!) {
|
||||||
bookOne(id: $id) {
|
bookOne(id: $id) {
|
||||||
...Book
|
...Book
|
||||||
|
|
@ -232,15 +108,3 @@ const bookQuery = gql`
|
||||||
}
|
}
|
||||||
${bookFragment}
|
${bookFragment}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const bookChapterListQuery = gql`
|
|
||||||
query BookChapterList($input: BookChapterManyInput!) {
|
|
||||||
bookChapterMany(input: $input) {
|
|
||||||
items {
|
|
||||||
...BookChapter
|
|
||||||
}
|
|
||||||
total
|
|
||||||
}
|
|
||||||
}
|
|
||||||
${bookChapterFragment}
|
|
||||||
`;
|
|
||||||
|
|
|
||||||
177
app/client/pages/work/book/$bookId/overview/contents.tsx
Normal file
177
app/client/pages/work/book/$bookId/overview/contents.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
import { gql, useMutation, useQuery } from '@apollo/client';
|
||||||
|
import { BookChapterCard } from '@app/client/components/BookChapterCard';
|
||||||
|
import { BookOverviewTabs } from '@app/client/components/BookOverviewTabs';
|
||||||
|
import { PaginatedListSkeleton } from '@app/client/components/PaginatedListSkeleton';
|
||||||
|
import { Pagination } from '@app/client/components/Pagination';
|
||||||
|
import { RatingPanel } from '@app/client/components/RatingPanel';
|
||||||
|
import { Button } from '@app/client/components/ui/button';
|
||||||
|
import { bookChapterFragment } from '@app/client/fragments';
|
||||||
|
import { useIdentity } from '@app/client/hooks/identity';
|
||||||
|
import { usePaginatedQuery } from '@app/client/hooks/usePaginatedQuery';
|
||||||
|
import { Box, Skeleton, Stack, Text } from '@chakra-ui/react';
|
||||||
|
import { BookChapterListQuery, BookOverviewRatingQuery } from '@generated/types';
|
||||||
|
import { createFileRoute, Link } from '@tanstack/react-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/work/book/$bookId/overview/contents')({
|
||||||
|
component: RouteComponent
|
||||||
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const { bookId: id } = Route.useParams();
|
||||||
|
|
||||||
|
const identity = useIdentity();
|
||||||
|
|
||||||
|
const { data } = useQuery<BookOverviewRatingQuery>(bookRatingQuery, {
|
||||||
|
variables: { id }
|
||||||
|
});
|
||||||
|
|
||||||
|
const [ratingUpdate] = useMutation<BookOverviewRatingQuery>(bookRatingMutation, {
|
||||||
|
refetchQueries: [bookRatingQuery]
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<BookOverviewTabs bookId={id} currentTab="contents" />
|
||||||
|
|
||||||
|
{data != null ? (
|
||||||
|
<>
|
||||||
|
<BookChapterList
|
||||||
|
id={data.bookOne.id}
|
||||||
|
isAuthor={identity?.data.id === data.bookOne.author.id}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{identity != null &&
|
||||||
|
identity?.data.id !== data.bookOne.author.id &&
|
||||||
|
data.bookOne.chaptersTotal > 0 && (
|
||||||
|
<RatingPanel
|
||||||
|
personalRating={data.bookOne.personalRating}
|
||||||
|
onRatingSet={async ratingType => {
|
||||||
|
await ratingUpdate({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
bookId: data.bookOne.id,
|
||||||
|
ratingType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Skeleton height="200px" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BookChapterList({ id, isAuthor }: { readonly id: string; readonly isAuthor: boolean }) {
|
||||||
|
const [chaptersListPage, setChaptersListPage] = useState<number>(1);
|
||||||
|
const [chaptersListPageSize, setChaptersListPageSize] = useState<number>(10);
|
||||||
|
const startIndex = (chaptersListPage - 1) * chaptersListPageSize;
|
||||||
|
|
||||||
|
const { data } = usePaginatedQuery<BookChapterListQuery>({
|
||||||
|
query: bookChapterListQuery,
|
||||||
|
input: {
|
||||||
|
bookId: id,
|
||||||
|
filter: {
|
||||||
|
offset: startIndex,
|
||||||
|
size: chaptersListPageSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
{data != null ? (
|
||||||
|
<>
|
||||||
|
<Stack>
|
||||||
|
{data.bookChapterMany.items.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{data.bookChapterMany.items.map(item => (
|
||||||
|
<Stack key={`bookChapterList-${item.id}`}>
|
||||||
|
<BookChapterCard
|
||||||
|
bookId={id}
|
||||||
|
chapterId={item.id}
|
||||||
|
title={item.title}
|
||||||
|
isAuthor={isAuthor}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Stack justifyContent="center" alignItems="center" pt={8}>
|
||||||
|
<Text>No chapters here yet!</Text>
|
||||||
|
{isAuthor && (
|
||||||
|
<Link to={`/work/book/${id}/chapter/new`}>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Create new chapter
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
{data.bookChapterMany.total > chaptersListPageSize && (
|
||||||
|
<Stack justifyContent="center" alignItems="center">
|
||||||
|
<Pagination
|
||||||
|
total={data.bookChapterMany.total}
|
||||||
|
page={chaptersListPage}
|
||||||
|
setPage={setChaptersListPage}
|
||||||
|
pageSize={chaptersListPageSize}
|
||||||
|
setPageSize={setChaptersListPageSize}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Stack direction="row">
|
||||||
|
<>
|
||||||
|
{isAuthor && data.bookChapterMany.items.length !== 0 && (
|
||||||
|
<Box>
|
||||||
|
<Link to={`/work/book/${id}/chapter/new`}>
|
||||||
|
<Button size="sm">Create new chapter</Button>
|
||||||
|
</Link>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<PaginatedListSkeleton disableSearch />
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bookChapterListQuery = gql`
|
||||||
|
query BookChapterList($input: BookChapterManyInput!) {
|
||||||
|
bookChapterMany(input: $input) {
|
||||||
|
items {
|
||||||
|
...BookChapter
|
||||||
|
}
|
||||||
|
total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
${bookChapterFragment}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const bookRatingQuery = gql`
|
||||||
|
query BookOverviewRating($id: ID!) {
|
||||||
|
bookOne(id: $id) {
|
||||||
|
id
|
||||||
|
ratingTotal
|
||||||
|
personalRating
|
||||||
|
chaptersTotal
|
||||||
|
author {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const bookRatingMutation = gql`
|
||||||
|
mutation BookOverviewRatingUpdate($input: RatingSetInput!) {
|
||||||
|
ratingSet(input: $input)
|
||||||
|
}
|
||||||
|
`;
|
||||||
119
app/client/pages/work/book/$bookId/overview/reviews.tsx
Normal file
119
app/client/pages/work/book/$bookId/overview/reviews.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { gql } from '@apollo/client';
|
||||||
|
import { BookOverviewTabs } from '@app/client/components/BookOverviewTabs';
|
||||||
|
import { Pagination } from '@app/client/components/Pagination';
|
||||||
|
import { reviewFragment } from '@app/client/fragments';
|
||||||
|
import { useIdentity } from '@app/client/hooks/identity';
|
||||||
|
import { usePaginatedQuery } from '@app/client/hooks/usePaginatedQuery';
|
||||||
|
import { useToastMutation } from '@app/client/hooks/useToastMutation';
|
||||||
|
import { Skeleton, Stack, Text } from '@chakra-ui/react';
|
||||||
|
import { BookReviewCreateMutation, BookReviewsQuery } from '@generated/types';
|
||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { bookQuery } from '../overview';
|
||||||
|
import { ReviewInput } from '@app/client/components/ReviewInput';
|
||||||
|
import { ReviewCard } from '@app/client/components/ReviewCard';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/work/book/$bookId/overview/reviews')({
|
||||||
|
component: RouteComponent
|
||||||
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const { bookId: id } = Route.useParams();
|
||||||
|
const identity = useIdentity();
|
||||||
|
|
||||||
|
const [reviewsPage, setChaptersListPage] = useState<number>(1);
|
||||||
|
const [reviewsPageSize, setChaptersListPageSize] = useState<number>(10);
|
||||||
|
const startIndex = (reviewsPage - 1) * reviewsPageSize;
|
||||||
|
|
||||||
|
const { data } = usePaginatedQuery<BookReviewsQuery>({
|
||||||
|
query: bookReviewsQuery,
|
||||||
|
input: {
|
||||||
|
bookId: id,
|
||||||
|
filter: {
|
||||||
|
offset: startIndex,
|
||||||
|
size: reviewsPageSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { update: reviewCreate } = useToastMutation<BookReviewCreateMutation>({
|
||||||
|
mutation: bookReviewCreateMutation,
|
||||||
|
successMsg: 'Review posted!',
|
||||||
|
refetchQueries: [bookReviewsQuery, bookQuery]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data == null) return <Skeleton />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<BookOverviewTabs bookId={id} currentTab="reviews" />
|
||||||
|
|
||||||
|
{identity != null && reviewsPage === 1 && (
|
||||||
|
<ReviewInput
|
||||||
|
username={identity?.data.user.username}
|
||||||
|
onReviewSubmit={async text => {
|
||||||
|
await reviewCreate({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
bookId: id,
|
||||||
|
text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.reviewMany.items.length === 0 ? (
|
||||||
|
<Stack justifyContent="center" alignItems="center" pt={8}>
|
||||||
|
<Text>No reviews here yet!</Text>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
data.reviewMany.items.map(item => (
|
||||||
|
<ReviewCard
|
||||||
|
key={`bookReview#${item.id}`}
|
||||||
|
review={{
|
||||||
|
id: item.id,
|
||||||
|
username: item.author.username,
|
||||||
|
text: item.text,
|
||||||
|
createdAt: item.createdAt
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.reviewMany.total > reviewsPageSize && (
|
||||||
|
<Stack justifyContent="center" alignItems="center">
|
||||||
|
<Pagination
|
||||||
|
total={data.reviewMany.total}
|
||||||
|
page={reviewsPage}
|
||||||
|
setPage={setChaptersListPage}
|
||||||
|
pageSize={reviewsPageSize}
|
||||||
|
setPageSize={setChaptersListPageSize}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookReviewsQuery = gql`
|
||||||
|
query BookReviews($input: ReviewManyInput!) {
|
||||||
|
reviewMany(input: $input) {
|
||||||
|
items {
|
||||||
|
...Review
|
||||||
|
}
|
||||||
|
total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
${reviewFragment}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const bookReviewCreateMutation = gql`
|
||||||
|
mutation BookReviewCreate($input: ReviewCreateInput!) {
|
||||||
|
reviewCreate(input: $input) {
|
||||||
|
...Review
|
||||||
|
}
|
||||||
|
}
|
||||||
|
${reviewFragment}
|
||||||
|
`;
|
||||||
|
|
@ -19,6 +19,8 @@ import { Route as IndexImport } from './pages/index'
|
||||||
import { Route as ProfileUsernameImport } from './pages/profile/$username'
|
import { Route as ProfileUsernameImport } from './pages/profile/$username'
|
||||||
import { Route as NewBookImport } from './pages/new/book'
|
import { Route as NewBookImport } from './pages/new/book'
|
||||||
import { Route as WorkBookBookIdOverviewImport } from './pages/work/book/$bookId/overview'
|
import { Route as WorkBookBookIdOverviewImport } from './pages/work/book/$bookId/overview'
|
||||||
|
import { Route as WorkBookBookIdOverviewReviewsImport } from './pages/work/book/$bookId/overview/reviews'
|
||||||
|
import { Route as WorkBookBookIdOverviewContentsImport } from './pages/work/book/$bookId/overview/contents'
|
||||||
import { Route as WorkBookBookIdChapterNewImport } from './pages/work/book/$bookId/chapter/new'
|
import { Route as WorkBookBookIdChapterNewImport } from './pages/work/book/$bookId/chapter/new'
|
||||||
import { Route as WorkBookBookIdChapterChapterIdViewImport } from './pages/work/book/$bookId/chapter/$chapterId/view'
|
import { Route as WorkBookBookIdChapterChapterIdViewImport } from './pages/work/book/$bookId/chapter/$chapterId/view'
|
||||||
import { Route as WorkBookBookIdChapterChapterIdEditImport } from './pages/work/book/$bookId/chapter/$chapterId/edit'
|
import { Route as WorkBookBookIdChapterChapterIdEditImport } from './pages/work/book/$bookId/chapter/$chapterId/edit'
|
||||||
|
|
@ -73,6 +75,20 @@ const WorkBookBookIdOverviewRoute = WorkBookBookIdOverviewImport.update({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
|
||||||
|
const WorkBookBookIdOverviewReviewsRoute =
|
||||||
|
WorkBookBookIdOverviewReviewsImport.update({
|
||||||
|
id: '/reviews',
|
||||||
|
path: '/reviews',
|
||||||
|
getParentRoute: () => WorkBookBookIdOverviewRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
const WorkBookBookIdOverviewContentsRoute =
|
||||||
|
WorkBookBookIdOverviewContentsImport.update({
|
||||||
|
id: '/contents',
|
||||||
|
path: '/contents',
|
||||||
|
getParentRoute: () => WorkBookBookIdOverviewRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
const WorkBookBookIdChapterNewRoute = WorkBookBookIdChapterNewImport.update({
|
const WorkBookBookIdChapterNewRoute = WorkBookBookIdChapterNewImport.update({
|
||||||
id: '/work/book/$bookId/chapter/new',
|
id: '/work/book/$bookId/chapter/new',
|
||||||
path: '/work/book/$bookId/chapter/new',
|
path: '/work/book/$bookId/chapter/new',
|
||||||
|
|
@ -160,6 +176,20 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof WorkBookBookIdChapterNewImport
|
preLoaderRoute: typeof WorkBookBookIdChapterNewImport
|
||||||
parentRoute: typeof rootRoute
|
parentRoute: typeof rootRoute
|
||||||
}
|
}
|
||||||
|
'/work/book/$bookId/overview/contents': {
|
||||||
|
id: '/work/book/$bookId/overview/contents'
|
||||||
|
path: '/contents'
|
||||||
|
fullPath: '/work/book/$bookId/overview/contents'
|
||||||
|
preLoaderRoute: typeof WorkBookBookIdOverviewContentsImport
|
||||||
|
parentRoute: typeof WorkBookBookIdOverviewImport
|
||||||
|
}
|
||||||
|
'/work/book/$bookId/overview/reviews': {
|
||||||
|
id: '/work/book/$bookId/overview/reviews'
|
||||||
|
path: '/reviews'
|
||||||
|
fullPath: '/work/book/$bookId/overview/reviews'
|
||||||
|
preLoaderRoute: typeof WorkBookBookIdOverviewReviewsImport
|
||||||
|
parentRoute: typeof WorkBookBookIdOverviewImport
|
||||||
|
}
|
||||||
'/work/book/$bookId/chapter/$chapterId/edit': {
|
'/work/book/$bookId/chapter/$chapterId/edit': {
|
||||||
id: '/work/book/$bookId/chapter/$chapterId/edit'
|
id: '/work/book/$bookId/chapter/$chapterId/edit'
|
||||||
path: '/work/book/$bookId/chapter/$chapterId/edit'
|
path: '/work/book/$bookId/chapter/$chapterId/edit'
|
||||||
|
|
@ -179,6 +209,22 @@ declare module '@tanstack/react-router' {
|
||||||
|
|
||||||
// Create and export the route tree
|
// Create and export the route tree
|
||||||
|
|
||||||
|
interface WorkBookBookIdOverviewRouteChildren {
|
||||||
|
WorkBookBookIdOverviewContentsRoute: typeof WorkBookBookIdOverviewContentsRoute
|
||||||
|
WorkBookBookIdOverviewReviewsRoute: typeof WorkBookBookIdOverviewReviewsRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const WorkBookBookIdOverviewRouteChildren: WorkBookBookIdOverviewRouteChildren =
|
||||||
|
{
|
||||||
|
WorkBookBookIdOverviewContentsRoute: WorkBookBookIdOverviewContentsRoute,
|
||||||
|
WorkBookBookIdOverviewReviewsRoute: WorkBookBookIdOverviewReviewsRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const WorkBookBookIdOverviewRouteWithChildren =
|
||||||
|
WorkBookBookIdOverviewRoute._addFileChildren(
|
||||||
|
WorkBookBookIdOverviewRouteChildren,
|
||||||
|
)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/about': typeof AboutRoute
|
'/about': typeof AboutRoute
|
||||||
|
|
@ -187,8 +233,10 @@ export interface FileRoutesByFullPath {
|
||||||
'/register': typeof RegisterRoute
|
'/register': typeof RegisterRoute
|
||||||
'/new/book': typeof NewBookRoute
|
'/new/book': typeof NewBookRoute
|
||||||
'/profile/$username': typeof ProfileUsernameRoute
|
'/profile/$username': typeof ProfileUsernameRoute
|
||||||
'/work/book/$bookId/overview': typeof WorkBookBookIdOverviewRoute
|
'/work/book/$bookId/overview': typeof WorkBookBookIdOverviewRouteWithChildren
|
||||||
'/work/book/$bookId/chapter/new': typeof WorkBookBookIdChapterNewRoute
|
'/work/book/$bookId/chapter/new': typeof WorkBookBookIdChapterNewRoute
|
||||||
|
'/work/book/$bookId/overview/contents': typeof WorkBookBookIdOverviewContentsRoute
|
||||||
|
'/work/book/$bookId/overview/reviews': typeof WorkBookBookIdOverviewReviewsRoute
|
||||||
'/work/book/$bookId/chapter/$chapterId/edit': typeof WorkBookBookIdChapterChapterIdEditRoute
|
'/work/book/$bookId/chapter/$chapterId/edit': typeof WorkBookBookIdChapterChapterIdEditRoute
|
||||||
'/work/book/$bookId/chapter/$chapterId/view': typeof WorkBookBookIdChapterChapterIdViewRoute
|
'/work/book/$bookId/chapter/$chapterId/view': typeof WorkBookBookIdChapterChapterIdViewRoute
|
||||||
}
|
}
|
||||||
|
|
@ -201,8 +249,10 @@ export interface FileRoutesByTo {
|
||||||
'/register': typeof RegisterRoute
|
'/register': typeof RegisterRoute
|
||||||
'/new/book': typeof NewBookRoute
|
'/new/book': typeof NewBookRoute
|
||||||
'/profile/$username': typeof ProfileUsernameRoute
|
'/profile/$username': typeof ProfileUsernameRoute
|
||||||
'/work/book/$bookId/overview': typeof WorkBookBookIdOverviewRoute
|
'/work/book/$bookId/overview': typeof WorkBookBookIdOverviewRouteWithChildren
|
||||||
'/work/book/$bookId/chapter/new': typeof WorkBookBookIdChapterNewRoute
|
'/work/book/$bookId/chapter/new': typeof WorkBookBookIdChapterNewRoute
|
||||||
|
'/work/book/$bookId/overview/contents': typeof WorkBookBookIdOverviewContentsRoute
|
||||||
|
'/work/book/$bookId/overview/reviews': typeof WorkBookBookIdOverviewReviewsRoute
|
||||||
'/work/book/$bookId/chapter/$chapterId/edit': typeof WorkBookBookIdChapterChapterIdEditRoute
|
'/work/book/$bookId/chapter/$chapterId/edit': typeof WorkBookBookIdChapterChapterIdEditRoute
|
||||||
'/work/book/$bookId/chapter/$chapterId/view': typeof WorkBookBookIdChapterChapterIdViewRoute
|
'/work/book/$bookId/chapter/$chapterId/view': typeof WorkBookBookIdChapterChapterIdViewRoute
|
||||||
}
|
}
|
||||||
|
|
@ -216,8 +266,10 @@ export interface FileRoutesById {
|
||||||
'/register': typeof RegisterRoute
|
'/register': typeof RegisterRoute
|
||||||
'/new/book': typeof NewBookRoute
|
'/new/book': typeof NewBookRoute
|
||||||
'/profile/$username': typeof ProfileUsernameRoute
|
'/profile/$username': typeof ProfileUsernameRoute
|
||||||
'/work/book/$bookId/overview': typeof WorkBookBookIdOverviewRoute
|
'/work/book/$bookId/overview': typeof WorkBookBookIdOverviewRouteWithChildren
|
||||||
'/work/book/$bookId/chapter/new': typeof WorkBookBookIdChapterNewRoute
|
'/work/book/$bookId/chapter/new': typeof WorkBookBookIdChapterNewRoute
|
||||||
|
'/work/book/$bookId/overview/contents': typeof WorkBookBookIdOverviewContentsRoute
|
||||||
|
'/work/book/$bookId/overview/reviews': typeof WorkBookBookIdOverviewReviewsRoute
|
||||||
'/work/book/$bookId/chapter/$chapterId/edit': typeof WorkBookBookIdChapterChapterIdEditRoute
|
'/work/book/$bookId/chapter/$chapterId/edit': typeof WorkBookBookIdChapterChapterIdEditRoute
|
||||||
'/work/book/$bookId/chapter/$chapterId/view': typeof WorkBookBookIdChapterChapterIdViewRoute
|
'/work/book/$bookId/chapter/$chapterId/view': typeof WorkBookBookIdChapterChapterIdViewRoute
|
||||||
}
|
}
|
||||||
|
|
@ -234,6 +286,8 @@ export interface FileRouteTypes {
|
||||||
| '/profile/$username'
|
| '/profile/$username'
|
||||||
| '/work/book/$bookId/overview'
|
| '/work/book/$bookId/overview'
|
||||||
| '/work/book/$bookId/chapter/new'
|
| '/work/book/$bookId/chapter/new'
|
||||||
|
| '/work/book/$bookId/overview/contents'
|
||||||
|
| '/work/book/$bookId/overview/reviews'
|
||||||
| '/work/book/$bookId/chapter/$chapterId/edit'
|
| '/work/book/$bookId/chapter/$chapterId/edit'
|
||||||
| '/work/book/$bookId/chapter/$chapterId/view'
|
| '/work/book/$bookId/chapter/$chapterId/view'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
|
|
@ -247,6 +301,8 @@ export interface FileRouteTypes {
|
||||||
| '/profile/$username'
|
| '/profile/$username'
|
||||||
| '/work/book/$bookId/overview'
|
| '/work/book/$bookId/overview'
|
||||||
| '/work/book/$bookId/chapter/new'
|
| '/work/book/$bookId/chapter/new'
|
||||||
|
| '/work/book/$bookId/overview/contents'
|
||||||
|
| '/work/book/$bookId/overview/reviews'
|
||||||
| '/work/book/$bookId/chapter/$chapterId/edit'
|
| '/work/book/$bookId/chapter/$chapterId/edit'
|
||||||
| '/work/book/$bookId/chapter/$chapterId/view'
|
| '/work/book/$bookId/chapter/$chapterId/view'
|
||||||
id:
|
id:
|
||||||
|
|
@ -260,6 +316,8 @@ export interface FileRouteTypes {
|
||||||
| '/profile/$username'
|
| '/profile/$username'
|
||||||
| '/work/book/$bookId/overview'
|
| '/work/book/$bookId/overview'
|
||||||
| '/work/book/$bookId/chapter/new'
|
| '/work/book/$bookId/chapter/new'
|
||||||
|
| '/work/book/$bookId/overview/contents'
|
||||||
|
| '/work/book/$bookId/overview/reviews'
|
||||||
| '/work/book/$bookId/chapter/$chapterId/edit'
|
| '/work/book/$bookId/chapter/$chapterId/edit'
|
||||||
| '/work/book/$bookId/chapter/$chapterId/view'
|
| '/work/book/$bookId/chapter/$chapterId/view'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
|
|
@ -273,7 +331,7 @@ export interface RootRouteChildren {
|
||||||
RegisterRoute: typeof RegisterRoute
|
RegisterRoute: typeof RegisterRoute
|
||||||
NewBookRoute: typeof NewBookRoute
|
NewBookRoute: typeof NewBookRoute
|
||||||
ProfileUsernameRoute: typeof ProfileUsernameRoute
|
ProfileUsernameRoute: typeof ProfileUsernameRoute
|
||||||
WorkBookBookIdOverviewRoute: typeof WorkBookBookIdOverviewRoute
|
WorkBookBookIdOverviewRoute: typeof WorkBookBookIdOverviewRouteWithChildren
|
||||||
WorkBookBookIdChapterNewRoute: typeof WorkBookBookIdChapterNewRoute
|
WorkBookBookIdChapterNewRoute: typeof WorkBookBookIdChapterNewRoute
|
||||||
WorkBookBookIdChapterChapterIdEditRoute: typeof WorkBookBookIdChapterChapterIdEditRoute
|
WorkBookBookIdChapterChapterIdEditRoute: typeof WorkBookBookIdChapterChapterIdEditRoute
|
||||||
WorkBookBookIdChapterChapterIdViewRoute: typeof WorkBookBookIdChapterChapterIdViewRoute
|
WorkBookBookIdChapterChapterIdViewRoute: typeof WorkBookBookIdChapterChapterIdViewRoute
|
||||||
|
|
@ -287,7 +345,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||||
RegisterRoute: RegisterRoute,
|
RegisterRoute: RegisterRoute,
|
||||||
NewBookRoute: NewBookRoute,
|
NewBookRoute: NewBookRoute,
|
||||||
ProfileUsernameRoute: ProfileUsernameRoute,
|
ProfileUsernameRoute: ProfileUsernameRoute,
|
||||||
WorkBookBookIdOverviewRoute: WorkBookBookIdOverviewRoute,
|
WorkBookBookIdOverviewRoute: WorkBookBookIdOverviewRouteWithChildren,
|
||||||
WorkBookBookIdChapterNewRoute: WorkBookBookIdChapterNewRoute,
|
WorkBookBookIdChapterNewRoute: WorkBookBookIdChapterNewRoute,
|
||||||
WorkBookBookIdChapterChapterIdEditRoute:
|
WorkBookBookIdChapterChapterIdEditRoute:
|
||||||
WorkBookBookIdChapterChapterIdEditRoute,
|
WorkBookBookIdChapterChapterIdEditRoute,
|
||||||
|
|
@ -340,11 +398,23 @@ export const routeTree = rootRoute
|
||||||
"filePath": "profile/$username.tsx"
|
"filePath": "profile/$username.tsx"
|
||||||
},
|
},
|
||||||
"/work/book/$bookId/overview": {
|
"/work/book/$bookId/overview": {
|
||||||
"filePath": "work/book/$bookId/overview.tsx"
|
"filePath": "work/book/$bookId/overview.tsx",
|
||||||
|
"children": [
|
||||||
|
"/work/book/$bookId/overview/contents",
|
||||||
|
"/work/book/$bookId/overview/reviews"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"/work/book/$bookId/chapter/new": {
|
"/work/book/$bookId/chapter/new": {
|
||||||
"filePath": "work/book/$bookId/chapter/new.tsx"
|
"filePath": "work/book/$bookId/chapter/new.tsx"
|
||||||
},
|
},
|
||||||
|
"/work/book/$bookId/overview/contents": {
|
||||||
|
"filePath": "work/book/$bookId/overview/contents.tsx",
|
||||||
|
"parent": "/work/book/$bookId/overview"
|
||||||
|
},
|
||||||
|
"/work/book/$bookId/overview/reviews": {
|
||||||
|
"filePath": "work/book/$bookId/overview/reviews.tsx",
|
||||||
|
"parent": "/work/book/$bookId/overview"
|
||||||
|
},
|
||||||
"/work/book/$bookId/chapter/$chapterId/edit": {
|
"/work/book/$bookId/chapter/$chapterId/edit": {
|
||||||
"filePath": "work/book/$bookId/chapter/$chapterId/edit.tsx"
|
"filePath": "work/book/$bookId/chapter/$chapterId/edit.tsx"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -29,12 +29,17 @@ export function toAppConfig(): AppConfig {
|
||||||
const host = process.env.VITE_HOST || 'localhost';
|
const host = process.env.VITE_HOST || 'localhost';
|
||||||
const port = parseInt(process.env.VITE_PORT || '3000');
|
const port = parseInt(process.env.VITE_PORT || '3000');
|
||||||
|
|
||||||
|
const hostRemote = process.env.VITE_HOST_REMOTE || 'localhost';
|
||||||
|
const domainName = process.env.VITE_DOMAIN_NAME || 'localhost';
|
||||||
|
|
||||||
|
const urlIp = `http://${hostRemote}:${port}`;
|
||||||
|
const urlDomain = `http://${domainName}:${port}`;
|
||||||
const urlRemote = `https://${host}:${port}`;
|
const urlRemote = `https://${host}:${port}`;
|
||||||
const urlLocal = host !== 'localhost' ? `https://localhost:${port}` : undefined;
|
const urlLocal = host !== 'localhost' ? `https://localhost:${port}` : undefined;
|
||||||
|
|
||||||
const limit = { limit: '50mb' };
|
const limit = { limit: '50mb' };
|
||||||
|
|
||||||
const origin = urlLocal != null ? [urlLocal, urlRemote] : [urlRemote];
|
const origin = urlLocal != null ? [urlLocal, urlRemote, urlIp, urlDomain] : [urlRemote];
|
||||||
const corsOptions: cors.CorsOptions = {
|
const corsOptions: cors.CorsOptions = {
|
||||||
origin,
|
origin,
|
||||||
credentials: true
|
credentials: true
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { BookChapterOneResolver } from './resolvers/bookChapterOneResolver';
|
||||||
import { BookManyResolver } from './resolvers/bookManyResolver';
|
import { BookManyResolver } from './resolvers/bookManyResolver';
|
||||||
import { BookOneResolver } from './resolvers/bookOneResolver';
|
import { BookOneResolver } from './resolvers/bookOneResolver';
|
||||||
import { IdentityResolver } from './resolvers/identityResolver';
|
import { IdentityResolver } from './resolvers/identityResolver';
|
||||||
|
import { ReviewManyResolver } from './resolvers/reviewManyResolver';
|
||||||
import { UserOneResolver } from './resolvers/userOneResolver';
|
import { UserOneResolver } from './resolvers/userOneResolver';
|
||||||
|
|
||||||
/* Mutation */
|
/* Mutation */
|
||||||
|
|
@ -17,6 +18,8 @@ import { BookChapterCreateResolver } from './resolvers/bookChapterCreateResolver
|
||||||
import { BookChapterDeleteResolver } from './resolvers/bookChapterDelete';
|
import { BookChapterDeleteResolver } from './resolvers/bookChapterDelete';
|
||||||
import { BookChapterUpdateResolver } from './resolvers/bookChapterUpdateResolver';
|
import { BookChapterUpdateResolver } from './resolvers/bookChapterUpdateResolver';
|
||||||
import { BookCreateResolver } from './resolvers/bookCreateResolver';
|
import { BookCreateResolver } from './resolvers/bookCreateResolver';
|
||||||
|
import { RatingSetResolver } from './resolvers/ratingSetResolver';
|
||||||
|
import { ReviewCreateResolver } from './resolvers/reviewCreateResolver';
|
||||||
|
|
||||||
export const Query: QueryResolvers = {
|
export const Query: QueryResolvers = {
|
||||||
bookOne: BookOneResolver,
|
bookOne: BookOneResolver,
|
||||||
|
|
@ -24,6 +27,7 @@ export const Query: QueryResolvers = {
|
||||||
bookChapterOne: BookChapterOneResolver,
|
bookChapterOne: BookChapterOneResolver,
|
||||||
bookChapterMany: BookChapterManyResolver,
|
bookChapterMany: BookChapterManyResolver,
|
||||||
identity: IdentityResolver,
|
identity: IdentityResolver,
|
||||||
|
reviewMany: ReviewManyResolver,
|
||||||
userOne: UserOneResolver
|
userOne: UserOneResolver
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -34,7 +38,9 @@ export const Mutation: MutationResolvers = {
|
||||||
bookCreate: BookCreateResolver,
|
bookCreate: BookCreateResolver,
|
||||||
bookChapterCreate: BookChapterCreateResolver,
|
bookChapterCreate: BookChapterCreateResolver,
|
||||||
bookChapterDelete: BookChapterDeleteResolver,
|
bookChapterDelete: BookChapterDeleteResolver,
|
||||||
bookChapterUpdate: BookChapterUpdateResolver
|
bookChapterUpdate: BookChapterUpdateResolver,
|
||||||
|
ratingSet: RatingSetResolver,
|
||||||
|
reviewCreate: ReviewCreateResolver
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Subscription = {};
|
export const Subscription = {};
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,29 @@ export async function BookChapterDeleteResolver(
|
||||||
ensureIdentity(identity);
|
ensureIdentity(identity);
|
||||||
const model = await prisma.bookChapter.findUniqueOrThrow({
|
const model = await prisma.bookChapter.findUniqueOrThrow({
|
||||||
where: { id },
|
where: { id },
|
||||||
select: { book: { select: { id: true, authorId: true } } }
|
select: {
|
||||||
|
sequenceNumber: true,
|
||||||
|
book: { select: { id: true, authorId: true } }
|
||||||
|
}
|
||||||
});
|
});
|
||||||
if (identity.id !== model.book.authorId) throw new Error('Operation is not permitted.');
|
if (identity.id !== model.book.authorId) throw new Error('Operation is not permitted.');
|
||||||
|
|
||||||
await prisma.bookChapter.delete({ where: { id } });
|
await prisma.bookChapter.delete({ where: { id } });
|
||||||
|
|
||||||
|
await prisma.bookChapter.updateMany({
|
||||||
|
where: {
|
||||||
|
bookId: model.book.id,
|
||||||
|
sequenceNumber: {
|
||||||
|
gt: model.sequenceNumber
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
sequenceNumber: {
|
||||||
|
decrement: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
await updateBookStats(model.book.id);
|
await updateBookStats(model.book.id);
|
||||||
const book = await prisma.book.findUniqueOrThrow({ where: { id: model.book.id } });
|
const book = await prisma.book.findUniqueOrThrow({ where: { id: model.book.id } });
|
||||||
return toBookSchema(book, { req, res, identity });
|
return toBookSchema(book, { req, res, identity });
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,26 @@ export async function BookChapterUpdateResolver(
|
||||||
): Promise<BookChapter> {
|
): Promise<BookChapter> {
|
||||||
ensureIdentity(identity);
|
ensureIdentity(identity);
|
||||||
|
|
||||||
|
const model = await prisma.bookChapter.findUniqueOrThrow({
|
||||||
|
where: { id: input.id },
|
||||||
|
select: {
|
||||||
|
book: {
|
||||||
|
select: {
|
||||||
|
authorId: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (identity.id !== model.book.authorId) throw new Error('Operation is not permitted.');
|
||||||
|
|
||||||
if (input.title.length > 60) throw new Error('Title is too long.');
|
if (input.title.length > 60) throw new Error('Title is too long.');
|
||||||
if (input.foreword != null && input.foreword.length > 500)
|
if (input.foreword != null && input.foreword.length > 500)
|
||||||
throw new Error('Foreword is too long.');
|
throw new Error('Foreword is too long.');
|
||||||
if (input.footnote != null && input.footnote.length > 500)
|
if (input.footnote != null && input.footnote.length > 500)
|
||||||
throw new Error('Footnote is too long.');
|
throw new Error('Footnote is too long.');
|
||||||
|
|
||||||
const model = await prisma.bookChapter.update({
|
const modelUpdated = await prisma.bookChapter.update({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
data: {
|
data: {
|
||||||
title: input.title,
|
title: input.title,
|
||||||
|
|
@ -29,6 +42,6 @@ export async function BookChapterUpdateResolver(
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await updateBookStats(model.bookId);
|
await updateBookStats(modelUpdated.bookId);
|
||||||
return toBookChapterSchema(model, { req, res, identity });
|
return toBookChapterSchema(modelUpdated, { req, res, identity });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,10 @@ import { IdentityContext } from '@app/server/identity';
|
||||||
import { prisma } from '@app/server/prisma/client';
|
import { prisma } from '@app/server/prisma/client';
|
||||||
import {
|
import {
|
||||||
Book as BookSchema,
|
Book as BookSchema,
|
||||||
RatingType,
|
RatingType as RatingTypeSchema,
|
||||||
WorkingProgressStatus as WorkingProgressStatusSchema
|
WorkingProgressStatus as WorkingProgressStatusSchema
|
||||||
} from '@generated/types';
|
} from '@generated/types';
|
||||||
import { Book, WorkingProgressStatus } from '@prisma/client';
|
import { Book, RatingType, WorkingProgressStatus } from '@prisma/client';
|
||||||
import { toUserSchema } from './userMapper';
|
import { toUserSchema } from './userMapper';
|
||||||
|
|
||||||
export async function toBookSchema(
|
export async function toBookSchema(
|
||||||
|
|
@ -38,14 +38,22 @@ export async function toBookSchema(
|
||||||
});
|
});
|
||||||
return chaptersCount;
|
return chaptersCount;
|
||||||
}),
|
}),
|
||||||
// todo: ratings implementation
|
|
||||||
ratingTotal: await call(async () => {
|
ratingTotal: await call(async () => {
|
||||||
const ratingsCount = await prisma.rating.count({
|
const ratingsUpCount = await prisma.rating.count({
|
||||||
where: {
|
where: {
|
||||||
bookId: model.id
|
bookId: model.id,
|
||||||
|
type: RatingType.up
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return ratingsCount;
|
|
||||||
|
const ratingsDownCount = await prisma.rating.count({
|
||||||
|
where: {
|
||||||
|
bookId: model.id,
|
||||||
|
type: RatingType.down
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return ratingsUpCount - ratingsDownCount;
|
||||||
}),
|
}),
|
||||||
reviewsTotal: await call(async () => {
|
reviewsTotal: await call(async () => {
|
||||||
const reviewsCount = await prisma.review.count({
|
const reviewsCount = await prisma.review.count({
|
||||||
|
|
@ -56,16 +64,16 @@ export async function toBookSchema(
|
||||||
return reviewsCount;
|
return reviewsCount;
|
||||||
}),
|
}),
|
||||||
personalRating: await call(async () => {
|
personalRating: await call(async () => {
|
||||||
if (identity == null) return RatingType.None;
|
if (identity == null) return RatingTypeSchema.None;
|
||||||
const rating = await prisma.rating.findUnique({
|
const rating = await prisma.rating.findUnique({
|
||||||
where: {
|
where: {
|
||||||
userId_bookChapterId: {
|
userId_bookId: {
|
||||||
userId: identity.id,
|
userId: identity.id,
|
||||||
bookChapterId: model.id
|
bookId: model.id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (rating == null) return RatingType.None;
|
if (rating == null) return RatingTypeSchema.None;
|
||||||
ensureRatingType(rating.type);
|
ensureRatingType(rating.type);
|
||||||
return rating.type;
|
return rating.type;
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ export async function toReviewSchema(model: Review): Promise<ReviewSchema> {
|
||||||
return toUserSchema(userModel);
|
return toUserSchema(userModel);
|
||||||
}),
|
}),
|
||||||
text: model.text,
|
text: model.text,
|
||||||
|
createdAt: model.createdAt.toISOString(),
|
||||||
|
updatedAt: model.updatedAt.toISOString(),
|
||||||
// todo: ratings implementation
|
// todo: ratings implementation
|
||||||
ratingTotal: await call(async () => {
|
ratingTotal: await call(async () => {
|
||||||
return 0;
|
return 0;
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,12 @@ import { IdentityContext } from '@app/server/identity';
|
||||||
import { prisma, uuid } from '@app/server/prisma/client';
|
import { prisma, uuid } from '@app/server/prisma/client';
|
||||||
import { MutationRatingSetArgs, RatingType } from '@generated/types';
|
import { MutationRatingSetArgs, RatingType } from '@generated/types';
|
||||||
|
|
||||||
export async function BookCreateResolver(
|
export async function RatingSetResolver(
|
||||||
_: any,
|
_: any,
|
||||||
{ input }: MutationRatingSetArgs,
|
{ input }: MutationRatingSetArgs,
|
||||||
{ identity }: IdentityContext
|
{ identity }: IdentityContext
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
ensureIdentity(identity);
|
ensureIdentity(identity);
|
||||||
|
|
||||||
if (input.bookId != null) {
|
if (input.bookId != null) {
|
||||||
const rating = await prisma.rating.findUnique({
|
const rating = await prisma.rating.findUnique({
|
||||||
where: {
|
where: {
|
||||||
|
|
|
||||||
26
app/server/graphql/resolvers/reviewCreateResolver.ts
Normal file
26
app/server/graphql/resolvers/reviewCreateResolver.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { ensureIdentity } from '@app/server/helpers';
|
||||||
|
import { IdentityContext } from '@app/server/identity';
|
||||||
|
import { prisma, uuid } from '@app/server/prisma/client';
|
||||||
|
import { MutationReviewCreateArgs, Review } from '@generated/types';
|
||||||
|
import { toReviewSchema } from './mappers/reviewMapper';
|
||||||
|
|
||||||
|
export async function ReviewCreateResolver(
|
||||||
|
_: any,
|
||||||
|
{ input }: MutationReviewCreateArgs,
|
||||||
|
{ identity }: IdentityContext
|
||||||
|
): Promise<Review> {
|
||||||
|
ensureIdentity(identity);
|
||||||
|
|
||||||
|
if (input.text.length > 700) throw new Error('Review is too long.');
|
||||||
|
|
||||||
|
const model = await prisma.review.create({
|
||||||
|
data: {
|
||||||
|
id: uuid(),
|
||||||
|
text: input.text,
|
||||||
|
bookId: input.bookId,
|
||||||
|
userId: identity.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return toReviewSchema(model);
|
||||||
|
}
|
||||||
0
app/server/graphql/resolvers/reviewDeleteResolver.ts
Normal file
0
app/server/graphql/resolvers/reviewDeleteResolver.ts
Normal file
26
app/server/graphql/resolvers/reviewManyResolver.ts
Normal file
26
app/server/graphql/resolvers/reviewManyResolver.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { IdentityContext } from '@app/server/identity';
|
||||||
|
import { prisma } from '@app/server/prisma/client';
|
||||||
|
import { QueryReviewManyArgs, ReviewManyOutput } from '@generated/types';
|
||||||
|
import { toReviewSchema } from './mappers/reviewMapper';
|
||||||
|
|
||||||
|
export async function ReviewManyResolver(
|
||||||
|
_: any,
|
||||||
|
{ input }: QueryReviewManyArgs,
|
||||||
|
{ req, res, identity }: IdentityContext
|
||||||
|
): Promise<ReviewManyOutput> {
|
||||||
|
const { bookId, filter } = input;
|
||||||
|
|
||||||
|
const items = await prisma.review.findMany({
|
||||||
|
where: { bookId },
|
||||||
|
skip: filter.offset ?? 0,
|
||||||
|
take: filter.size ?? 10,
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: await Promise.all(items.map(value => toReviewSchema(value))),
|
||||||
|
total: await prisma.review.count({ where: { bookId } })
|
||||||
|
};
|
||||||
|
}
|
||||||
0
app/server/graphql/resolvers/reviewUpdateResolver.ts
Normal file
0
app/server/graphql/resolvers/reviewUpdateResolver.ts
Normal file
|
|
@ -97,6 +97,13 @@ type Book {
|
||||||
personalRating: RatingType!
|
personalRating: RatingType!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input BookUpdate {
|
||||||
|
id: ID!
|
||||||
|
title: String
|
||||||
|
description: String
|
||||||
|
progressStatus: WorkingProgressStatus
|
||||||
|
}
|
||||||
|
|
||||||
type BookChapter {
|
type BookChapter {
|
||||||
id: ID!
|
id: ID!
|
||||||
title: String!
|
title: String!
|
||||||
|
|
@ -144,6 +151,8 @@ type Review {
|
||||||
author: User!
|
author: User!
|
||||||
text: String!
|
text: String!
|
||||||
ratingTotal: Int!
|
ratingTotal: Int!
|
||||||
|
createdAt: String!
|
||||||
|
updatedAt: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
input ReviewCreateInput {
|
input ReviewCreateInput {
|
||||||
|
|
|
||||||
|
|
@ -9,5 +9,8 @@ export default defineConfig({
|
||||||
build: {
|
build: {
|
||||||
sourcemap: true
|
sourcemap: true
|
||||||
},
|
},
|
||||||
plugins: [TanStackRouterVite(), viteReact(), tsConfigPaths()]
|
plugins: [TanStackRouterVite(), viteReact(), tsConfigPaths()],
|
||||||
|
server: {
|
||||||
|
allowedHosts: ['n0q.dev']
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue