implemented ratings, fixes

This commit is contained in:
lnn0q 2025-06-01 17:54:57 +03:00
parent 4801cf3a13
commit d1da980501
31 changed files with 775 additions and 236 deletions

View file

@ -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">

View file

@ -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>

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -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>

View file

@ -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
}
`;

View file

@ -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`;

View file

@ -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,8 +170,10 @@ export const Route = createRootRouteWithContext<RouterContext>()({
</Stack> </Stack>
<Separator style={{ padding: '4px' }} /> <Separator style={{ padding: '4px' }} />
<Outlet /> <Outlet />
<Stack hidden>
<TanStackRouterDevtools /> <TanStackRouterDevtools />
</Stack> </Stack>
</Stack>
); );
} }
}); });

View file

@ -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`
}); });
})} })}
> >

View file

@ -51,7 +51,7 @@ function RouteComponent() {
} }
}); });
navigate({ to: `/work/book/${bookId}/overview` }); navigate({ to: `/work/book/${bookId}/overview/contents` });
}} }}
/> />
); );

View file

@ -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

View file

@ -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` });
}} }}
/> />
); );

View file

@ -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}
`;

View 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)
}
`;

View 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}
`;

View file

@ -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"
}, },

View file

@ -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

View file

@ -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 = {};

View file

@ -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 });

View file

@ -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 });
} }

View file

@ -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;
}) })

View file

@ -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;

View file

@ -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: {

View 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);
}

View 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 } })
};
}

View 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 {

View file

@ -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']
}
}); });