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 { bookFragment } from '../fragments';
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({
bookId,
@ -20,7 +21,7 @@ export function BookChapterCard({
const { update: chapterDelete } = useToastMutation<BookChapterDeleteMutation>({
mutation,
successMsg: 'Chapter deleted successfully',
refetchQueries: [bookChapterListQuery]
refetchQueries: [bookChapterListQuery, bookChapterQuery]
});
return (
<Card.Root width="100%" variant="elevated">

View file

@ -82,7 +82,7 @@ export function BookChapterForm({
</Box>
<Stack direction="row">
<Button onClick={async () => onSubmit()}>Submit</Button>
<Link to={`/work/book/${bookId}/overview`}>
<Link to={`/work/book/${bookId}/overview/contents`}>
<Button>Go back</Button>
</Link>
</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.Body>
<Stack>
<Stack direction="row" justifyContent="space-between">
<Stack direction={'row'} justifyContent="space-between">
<Text as="div" minWidth={0}>
<Link to={`/work/book/${id}/overview`}>
<Link to={`/work/book/${id}/overview/contents`}>
<Heading
color="colorPalette.400"
_hover={{
@ -37,7 +37,7 @@ export function WorkingListCard({
</Link>
</Text>
<Stack direction="row" justifyContent="center" alignItems="center">
<Stack direction="row" justifyContent="center" alignItems="center" hideBelow="md">
<WorkingRatingBadge ratingTotal={ratingTotal} />
<WorkingReviewsBadge reviewsTotal={reviewsTotal} />
<WorkingProgressStatusBadge progressStatus={progressStatus} />
@ -60,7 +60,7 @@ export function WorkingListCard({
</Link>
</Stack>
<Stack direction="row">
<Stack maxWidth="100%" direction="row" overflow="auto" flexWrap="wrap">
<Box>
<Badge size="md" variant="solid">
<Stack direction="row">
@ -88,6 +88,12 @@ export function WorkingListCard({
</Stack>
</Stack>
<Stack direction="row" justifyContent="left" alignItems="center" hideFrom="md">
<WorkingRatingBadge ratingTotal={ratingTotal} />
<WorkingReviewsBadge reviewsTotal={reviewsTotal} />
<WorkingProgressStatusBadge progressStatus={progressStatus} />
</Stack>
<Stack>
<Text lineClamp={4}>{description}</Text>
</Stack>

View file

@ -38,3 +38,16 @@ export const bookChapterFragment = gql`
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';
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';
export const url = `https://${host}:${port}`;
const graphqlUri = `${url}/graphql`;

View file

@ -96,6 +96,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
<Stack flexDirection="row" alignItems="center" gap={4}>
<Stack
hideBelow="md"
direction="row"
alignItems="center"
paddingY={1}
@ -124,7 +125,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
{identity != null ? (
<Menu.Root>
<Menu.Trigger css={{ '&:hover': { cursor: 'pointer' } }}>
<Avatar size="sm" />
<Avatar size="sm" fallback={identity.data.user.username[0]} />
</Menu.Trigger>
<Menu.Positioner mt={2}>
<Menu.Content backgroundColor={'bg.subtle'}>
@ -169,7 +170,9 @@ export const Route = createRootRouteWithContext<RouterContext>()({
</Stack>
<Separator style={{ padding: '4px' }} />
<Outlet />
<TanStackRouterDevtools />
<Stack hidden>
<TanStackRouterDevtools />
</Stack>
</Stack>
);
}

View file

@ -66,7 +66,7 @@ function NewBookForm() {
});
if (data != null)
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 { EditorSlate } from '@app/client/components/EditorSlate';
import { bookChapterFragment } from '@app/client/fragments';
import { Button, Heading, Icon, Skeleton, Stack, Text } from '@chakra-ui/react';
import { BookChapterViewQuery, BookChapterViewNavigationQuery } from '@generated/types';
import { Button, Heading, Icon, Separator, Skeleton, Stack, Text } from '@chakra-ui/react';
import { BookChapterViewNavigationQuery, BookChapterViewQuery } from '@generated/types';
import { createFileRoute, Link } from '@tanstack/react-router';
import { Descendant } from 'slate';
export const Route = createFileRoute('/work/book/$bookId/chapter/$chapterId/view')({
component: RouteComponent
@ -13,53 +12,35 @@ export const Route = createFileRoute('/work/book/$bookId/chapter/$chapterId/view
function RouteComponent() {
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, {
variables: { id: chapterId }
variables: { id: chapterId },
fetchPolicy: 'cache-and-network'
});
const { data: navigationData } = useQuery<BookChapterViewNavigationQuery>(
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 (
<Stack
maxWidth="840px"
@ -70,18 +51,38 @@ function BookChapterView({
>
<Heading as="div" maxWidth="100%">
<Text textAlign="center" minWidth={0}>
{chapter.book.title}
{data.bookChapterOne.book.title}
</Text>
<Text textAlign="center">-</Text>
<Text textAlign="center" minWidth={0}>
{chapter.title}
{data.bookChapterOne.title}
</Text>
</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
bookId={bookId}
previousChapterId={chapter.previousChapterId}
nextChapterId={chapter.nextChapterId}
previousChapterId={navigationData?.bookChapterOne.previousChapterId}
nextChapterId={navigationData?.bookChapterOne.nextChapterId}
/>
</Stack>
);
@ -108,7 +109,7 @@ function ChapterNavigation({
</Button>
</Link>
)}
<Link to={`/work/book/${bookId}/overview`}>
<Link to={`/work/book/${bookId}/overview/contents`}>
<Button
variant="ghost"
borderLeftRadius={previousChapterId ? 0 : undefined}
@ -134,7 +135,7 @@ function ChapterNavigation({
);
}
const bookChapterQuery = gql`
export const bookChapterQuery = gql`
query BookChapterView($id: ID!) {
bookChapterOne(id: $id) {
id
@ -149,7 +150,7 @@ const bookChapterQuery = gql`
${bookChapterFragment}
`;
export const bookChapterNavigationQuery = gql`
const bookChapterNavigationQuery = gql`
query BookChapterViewNavigation($id: ID!) {
bookChapterOne(id: $id) {
id

View file

@ -5,8 +5,7 @@ import { useToastMutation } from '@app/client/hooks/useToastMutation';
import { Skeleton } from '@chakra-ui/react';
import { BookChapterNewCreateMutation, BookChapterNewQuery } from '@generated/types';
import { createFileRoute, redirect, useNavigate } from '@tanstack/react-router';
import { bookChapterListQuery } from '../overview';
import { bookChapterNavigationQuery } from './$chapterId/view';
import { bookChapterListQuery } from '../overview/contents';
export const Route = createFileRoute('/work/book/$bookId/chapter/new')({
component: RouteComponent,
@ -28,12 +27,12 @@ function RouteComponent() {
const { update: chapterCreate } = useToastMutation<BookChapterNewCreateMutation>({
mutation,
successMsg: 'Chapter created successfully',
refetchQueries: [bookChapterListQuery, bookChapterNavigationQuery]
refetchQueries: [bookChapterListQuery]
});
if (data == null) return <Skeleton />;
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 (
<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 { 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 { WorkingRatingBadge } from '@app/client/components/WorkingRatingBadge';
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 { IdentityContext, useIdentity } from '@app/client/hooks/identity';
import { usePaginatedQuery } from '@app/client/hooks/usePaginatedQuery';
import { BookOne } from '@app/client/interfaces';
import { Box, Heading, HStack, Icon, Skeleton, SkeletonText, Stack, Text } from '@chakra-ui/react';
import { BookChapterListQuery, BookOverviewQuery } from '@generated/types';
import { createFileRoute, Link } from '@tanstack/react-router';
import { useState } from 'react';
import { Heading, HStack, Skeleton, SkeletonText, Stack, Text } from '@chakra-ui/react';
import { BookOverviewQuery } from '@generated/types';
import { createFileRoute, Link, Outlet } from '@tanstack/react-router';
export const Route = createFileRoute('/work/book/$bookId/overview')({
component: RouteComponent
@ -26,28 +19,16 @@ function RouteComponent() {
variables: { id }
});
const identity = useIdentity();
return (
<Stack width="100%" justifyContent="center" alignItems="center" padding={3}>
{bookData != null ? (
<BookOverview identity={identity} book={bookData.bookOne} />
) : (
<BookOverviewSkeleton />
)}
{bookData != null ? <BookOverview book={bookData.bookOne} /> : <BookOverviewSkeleton />}
</Stack>
);
}
function BookOverview({
identity,
book
}: {
readonly identity: IdentityContext | null;
readonly book: BookOne;
}) {
function BookOverview({ book }: { readonly book: BookOne }) {
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">
<Heading color="colorPalette.400" size="2xl" overflow="auto">
{book.title}
@ -101,119 +82,14 @@ function BookOverview({
<Text>{book.description}</Text>
<BookChapterList
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 />
)}
<Outlet />
</Stack>
);
}
function BookOverviewSkeleton() {
return (
<Stack width="80%" maxWidth="840px">
<Stack width={{ base: '100%', md: '80%' }} maxWidth="840px">
<Stack gap="6" maxWidth="100%">
<HStack width="full">
<SkeletonText noOfLines={5} />
@ -224,7 +100,7 @@ function BookOverviewSkeleton() {
);
}
const bookQuery = gql`
export const bookQuery = gql`
query BookOverview($id: ID!) {
bookOne(id: $id) {
...Book
@ -232,15 +108,3 @@ const bookQuery = gql`
}
${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 NewBookImport } from './pages/new/book'
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 WorkBookBookIdChapterChapterIdViewImport } from './pages/work/book/$bookId/chapter/$chapterId/view'
import { Route as WorkBookBookIdChapterChapterIdEditImport } from './pages/work/book/$bookId/chapter/$chapterId/edit'
@ -73,6 +75,20 @@ const WorkBookBookIdOverviewRoute = WorkBookBookIdOverviewImport.update({
getParentRoute: () => rootRoute,
} 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({
id: '/work/book/$bookId/chapter/new',
path: '/work/book/$bookId/chapter/new',
@ -160,6 +176,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof WorkBookBookIdChapterNewImport
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': {
id: '/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
interface WorkBookBookIdOverviewRouteChildren {
WorkBookBookIdOverviewContentsRoute: typeof WorkBookBookIdOverviewContentsRoute
WorkBookBookIdOverviewReviewsRoute: typeof WorkBookBookIdOverviewReviewsRoute
}
const WorkBookBookIdOverviewRouteChildren: WorkBookBookIdOverviewRouteChildren =
{
WorkBookBookIdOverviewContentsRoute: WorkBookBookIdOverviewContentsRoute,
WorkBookBookIdOverviewReviewsRoute: WorkBookBookIdOverviewReviewsRoute,
}
const WorkBookBookIdOverviewRouteWithChildren =
WorkBookBookIdOverviewRoute._addFileChildren(
WorkBookBookIdOverviewRouteChildren,
)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/about': typeof AboutRoute
@ -187,8 +233,10 @@ export interface FileRoutesByFullPath {
'/register': typeof RegisterRoute
'/new/book': typeof NewBookRoute
'/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/overview/contents': typeof WorkBookBookIdOverviewContentsRoute
'/work/book/$bookId/overview/reviews': typeof WorkBookBookIdOverviewReviewsRoute
'/work/book/$bookId/chapter/$chapterId/edit': typeof WorkBookBookIdChapterChapterIdEditRoute
'/work/book/$bookId/chapter/$chapterId/view': typeof WorkBookBookIdChapterChapterIdViewRoute
}
@ -201,8 +249,10 @@ export interface FileRoutesByTo {
'/register': typeof RegisterRoute
'/new/book': typeof NewBookRoute
'/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/overview/contents': typeof WorkBookBookIdOverviewContentsRoute
'/work/book/$bookId/overview/reviews': typeof WorkBookBookIdOverviewReviewsRoute
'/work/book/$bookId/chapter/$chapterId/edit': typeof WorkBookBookIdChapterChapterIdEditRoute
'/work/book/$bookId/chapter/$chapterId/view': typeof WorkBookBookIdChapterChapterIdViewRoute
}
@ -216,8 +266,10 @@ export interface FileRoutesById {
'/register': typeof RegisterRoute
'/new/book': typeof NewBookRoute
'/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/overview/contents': typeof WorkBookBookIdOverviewContentsRoute
'/work/book/$bookId/overview/reviews': typeof WorkBookBookIdOverviewReviewsRoute
'/work/book/$bookId/chapter/$chapterId/edit': typeof WorkBookBookIdChapterChapterIdEditRoute
'/work/book/$bookId/chapter/$chapterId/view': typeof WorkBookBookIdChapterChapterIdViewRoute
}
@ -234,6 +286,8 @@ export interface FileRouteTypes {
| '/profile/$username'
| '/work/book/$bookId/overview'
| '/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/view'
fileRoutesByTo: FileRoutesByTo
@ -247,6 +301,8 @@ export interface FileRouteTypes {
| '/profile/$username'
| '/work/book/$bookId/overview'
| '/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/view'
id:
@ -260,6 +316,8 @@ export interface FileRouteTypes {
| '/profile/$username'
| '/work/book/$bookId/overview'
| '/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/view'
fileRoutesById: FileRoutesById
@ -273,7 +331,7 @@ export interface RootRouteChildren {
RegisterRoute: typeof RegisterRoute
NewBookRoute: typeof NewBookRoute
ProfileUsernameRoute: typeof ProfileUsernameRoute
WorkBookBookIdOverviewRoute: typeof WorkBookBookIdOverviewRoute
WorkBookBookIdOverviewRoute: typeof WorkBookBookIdOverviewRouteWithChildren
WorkBookBookIdChapterNewRoute: typeof WorkBookBookIdChapterNewRoute
WorkBookBookIdChapterChapterIdEditRoute: typeof WorkBookBookIdChapterChapterIdEditRoute
WorkBookBookIdChapterChapterIdViewRoute: typeof WorkBookBookIdChapterChapterIdViewRoute
@ -287,7 +345,7 @@ const rootRouteChildren: RootRouteChildren = {
RegisterRoute: RegisterRoute,
NewBookRoute: NewBookRoute,
ProfileUsernameRoute: ProfileUsernameRoute,
WorkBookBookIdOverviewRoute: WorkBookBookIdOverviewRoute,
WorkBookBookIdOverviewRoute: WorkBookBookIdOverviewRouteWithChildren,
WorkBookBookIdChapterNewRoute: WorkBookBookIdChapterNewRoute,
WorkBookBookIdChapterChapterIdEditRoute:
WorkBookBookIdChapterChapterIdEditRoute,
@ -340,11 +398,23 @@ export const routeTree = rootRoute
"filePath": "profile/$username.tsx"
},
"/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": {
"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": {
"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 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 urlLocal = host !== 'localhost' ? `https://localhost:${port}` : undefined;
const limit = { limit: '50mb' };
const origin = urlLocal != null ? [urlLocal, urlRemote] : [urlRemote];
const origin = urlLocal != null ? [urlLocal, urlRemote, urlIp, urlDomain] : [urlRemote];
const corsOptions: cors.CorsOptions = {
origin,
credentials: true

View file

@ -7,6 +7,7 @@ import { BookChapterOneResolver } from './resolvers/bookChapterOneResolver';
import { BookManyResolver } from './resolvers/bookManyResolver';
import { BookOneResolver } from './resolvers/bookOneResolver';
import { IdentityResolver } from './resolvers/identityResolver';
import { ReviewManyResolver } from './resolvers/reviewManyResolver';
import { UserOneResolver } from './resolvers/userOneResolver';
/* Mutation */
@ -17,6 +18,8 @@ import { BookChapterCreateResolver } from './resolvers/bookChapterCreateResolver
import { BookChapterDeleteResolver } from './resolvers/bookChapterDelete';
import { BookChapterUpdateResolver } from './resolvers/bookChapterUpdateResolver';
import { BookCreateResolver } from './resolvers/bookCreateResolver';
import { RatingSetResolver } from './resolvers/ratingSetResolver';
import { ReviewCreateResolver } from './resolvers/reviewCreateResolver';
export const Query: QueryResolvers = {
bookOne: BookOneResolver,
@ -24,6 +27,7 @@ export const Query: QueryResolvers = {
bookChapterOne: BookChapterOneResolver,
bookChapterMany: BookChapterManyResolver,
identity: IdentityResolver,
reviewMany: ReviewManyResolver,
userOne: UserOneResolver
};
@ -34,7 +38,9 @@ export const Mutation: MutationResolvers = {
bookCreate: BookCreateResolver,
bookChapterCreate: BookChapterCreateResolver,
bookChapterDelete: BookChapterDeleteResolver,
bookChapterUpdate: BookChapterUpdateResolver
bookChapterUpdate: BookChapterUpdateResolver,
ratingSet: RatingSetResolver,
reviewCreate: ReviewCreateResolver
};
export const Subscription = {};

View file

@ -13,12 +13,29 @@ export async function BookChapterDeleteResolver(
ensureIdentity(identity);
const model = await prisma.bookChapter.findUniqueOrThrow({
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.');
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);
const book = await prisma.book.findUniqueOrThrow({ where: { id: model.book.id } });
return toBookSchema(book, { req, res, identity });

View file

@ -12,13 +12,26 @@ export async function BookChapterUpdateResolver(
): Promise<BookChapter> {
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.foreword != null && input.foreword.length > 500)
throw new Error('Foreword is too long.');
if (input.footnote != null && input.footnote.length > 500)
throw new Error('Footnote is too long.');
const model = await prisma.bookChapter.update({
const modelUpdated = await prisma.bookChapter.update({
where: { id: input.id },
data: {
title: input.title,
@ -29,6 +42,6 @@ export async function BookChapterUpdateResolver(
}
});
await updateBookStats(model.bookId);
return toBookChapterSchema(model, { req, res, identity });
await updateBookStats(modelUpdated.bookId);
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 {
Book as BookSchema,
RatingType,
RatingType as RatingTypeSchema,
WorkingProgressStatus as WorkingProgressStatusSchema
} from '@generated/types';
import { Book, WorkingProgressStatus } from '@prisma/client';
import { Book, RatingType, WorkingProgressStatus } from '@prisma/client';
import { toUserSchema } from './userMapper';
export async function toBookSchema(
@ -38,14 +38,22 @@ export async function toBookSchema(
});
return chaptersCount;
}),
// todo: ratings implementation
ratingTotal: await call(async () => {
const ratingsCount = await prisma.rating.count({
const ratingsUpCount = await prisma.rating.count({
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 () => {
const reviewsCount = await prisma.review.count({
@ -56,16 +64,16 @@ export async function toBookSchema(
return reviewsCount;
}),
personalRating: await call(async () => {
if (identity == null) return RatingType.None;
if (identity == null) return RatingTypeSchema.None;
const rating = await prisma.rating.findUnique({
where: {
userId_bookChapterId: {
userId_bookId: {
userId: identity.id,
bookChapterId: model.id
bookId: model.id
}
}
});
if (rating == null) return RatingType.None;
if (rating == null) return RatingTypeSchema.None;
ensureRatingType(rating.type);
return rating.type;
})

View file

@ -16,6 +16,8 @@ export async function toReviewSchema(model: Review): Promise<ReviewSchema> {
return toUserSchema(userModel);
}),
text: model.text,
createdAt: model.createdAt.toISOString(),
updatedAt: model.updatedAt.toISOString(),
// todo: ratings implementation
ratingTotal: await call(async () => {
return 0;

View file

@ -3,13 +3,12 @@ import { IdentityContext } from '@app/server/identity';
import { prisma, uuid } from '@app/server/prisma/client';
import { MutationRatingSetArgs, RatingType } from '@generated/types';
export async function BookCreateResolver(
export async function RatingSetResolver(
_: any,
{ input }: MutationRatingSetArgs,
{ identity }: IdentityContext
): Promise<void> {
ensureIdentity(identity);
if (input.bookId != null) {
const rating = await prisma.rating.findUnique({
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!
}
input BookUpdate {
id: ID!
title: String
description: String
progressStatus: WorkingProgressStatus
}
type BookChapter {
id: ID!
title: String!
@ -144,6 +151,8 @@ type Review {
author: User!
text: String!
ratingTotal: Int!
createdAt: String!
updatedAt: String!
}
input ReviewCreateInput {

View file

@ -9,5 +9,8 @@ export default defineConfig({
build: {
sourcemap: true
},
plugins: [TanStackRouterVite(), viteReact(), tsConfigPaths()]
plugins: [TanStackRouterVite(), viteReact(), tsConfigPaths()],
server: {
allowedHosts: ['n0q.dev']
}
});