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

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