added chapter view, fixes, added ratings, added chapter updates/deletions

This commit is contained in:
lnn0q 2025-06-01 00:37:26 +03:00
parent 029b7e9353
commit 4801cf3a13
34 changed files with 1345 additions and 191 deletions

View file

@ -0,0 +1,104 @@
import { gql, useQuery } from '@apollo/client';
import { BookChapterForm } from '@app/client/components/BookChapterForm';
import { useIdentity } from '@app/client/hooks/identity';
import { useToastMutation } from '@app/client/hooks/useToastMutation';
import { Skeleton } from '@chakra-ui/react';
import { BookChapterEditQuery, BookChapterNewCreateMutation } from '@generated/types';
import { createFileRoute, redirect, useNavigate } from '@tanstack/react-router';
export const Route = createFileRoute('/work/book/$bookId/chapter/$chapterId/edit')({
component: RouteComponent,
beforeLoad: ({ context }) => {
if (!context.isAuthenticated) throw redirect({ to: '/' });
}
});
function RouteComponent() {
const { bookId, chapterId } = Route.useParams();
const identity = useIdentity();
const navigate = useNavigate();
const { data } = useQuery<BookChapterEditQuery>(query, {
variables: { id: chapterId }
});
const { update: chapterUpdate } = useToastMutation<BookChapterNewCreateMutation>({
mutation,
successMsg: 'Chapter updated successfully'
});
if (data == null) return <Skeleton />;
if (identity?.data.user.id !== data.bookChapterOne.book.author.id)
throw redirect({ to: `/work/book/${bookId}/overview` });
return (
<BookChapterForm
bookId={bookId}
initialValues={{
title: data.bookChapterOne.title,
foreword: data.bookChapterOne.foreword != null ? data.bookChapterOne.foreword : undefined,
footnote: data.bookChapterOne.footnote != null ? data.bookChapterOne.footnote : undefined,
content: data.bookChapterOne.content
}}
onChapterSubmit={async chapter => {
await chapterUpdate({
variables: {
input: {
id: chapterId,
...chapter
}
}
});
navigate({ to: `/work/book/${bookId}/overview` });
}}
/>
);
}
const query = gql`
query BookChapterEdit($id: ID!) {
bookChapterOne(id: $id) {
id
title
foreword
footnote
content
previousChapterId
nextChapterId
book {
id
updatedAt
wordsTotal
chaptersTotal
author {
id
}
}
}
}
`;
const mutation = gql`
mutation BookChapterEditUpdate($input: BookChapterUpdateInput!) {
bookChapterUpdate(input: $input) {
id
title
foreword
footnote
content
previousChapterId
nextChapterId
book {
id
updatedAt
wordsTotal
chaptersTotal
author {
id
}
}
}
}
`;

View file

@ -0,0 +1,160 @@
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 { createFileRoute, Link } from '@tanstack/react-router';
import { Descendant } from 'slate';
export const Route = createFileRoute('/work/book/$bookId/chapter/$chapterId/view')({
component: RouteComponent
});
function RouteComponent() {
const { bookId, chapterId } = Route.useParams();
const { data } = useQuery<BookChapterViewQuery>(bookChapterQuery, {
variables: { id: chapterId }
});
const { data: navigationData } = useQuery<BookChapterViewNavigationQuery>(
bookChapterNavigationQuery,
{
variables: { id: chapterId }
}
);
if (data == null || navigationData == 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"
width="100%"
minHeight="80vh"
justifyContent="center"
alignItems="center"
>
<Heading as="div" maxWidth="100%">
<Text textAlign="center" minWidth={0}>
{chapter.book.title}
</Text>
<Text textAlign="center">-</Text>
<Text textAlign="center" minWidth={0}>
{chapter.title}
</Text>
</Heading>
<EditorSlate initialValue={chapter.content} readOnly />
<ChapterNavigation
bookId={bookId}
previousChapterId={chapter.previousChapterId}
nextChapterId={chapter.nextChapterId}
/>
</Stack>
);
}
function ChapterNavigation({
bookId,
previousChapterId,
nextChapterId
}: {
readonly bookId: string;
readonly previousChapterId?: string | null;
readonly nextChapterId?: string | null;
}) {
return (
<Stack direction="row" gap={0}>
{previousChapterId && (
<Link to={`/work/book/${bookId}/chapter/${previousChapterId}/view`}>
<Button variant="ghost" borderRightRadius={0}>
<Icon>
<i className="nf nf-md-page_previous" />
</Icon>
<Text>Prev</Text>
</Button>
</Link>
)}
<Link to={`/work/book/${bookId}/overview`}>
<Button
variant="ghost"
borderLeftRadius={previousChapterId ? 0 : undefined}
borderRightRadius={nextChapterId ? 0 : undefined}
>
<Icon>
<i className="nf nf-fa-table_list" />
</Icon>
<Text>Contents</Text>
</Button>
</Link>
{nextChapterId && (
<Link to={`/work/book/${bookId}/chapter/${nextChapterId}/view`}>
<Button variant="ghost" borderLeftRadius={0}>
<Text>Next</Text>
<Icon>
<i className="nf nf-md-page_next" />
</Icon>
</Button>
</Link>
)}
</Stack>
);
}
const bookChapterQuery = gql`
query BookChapterView($id: ID!) {
bookChapterOne(id: $id) {
id
content
...BookChapter
book {
title
chaptersTotal
}
}
}
${bookChapterFragment}
`;
export const bookChapterNavigationQuery = gql`
query BookChapterViewNavigation($id: ID!) {
bookChapterOne(id: $id) {
id
previousChapterId
nextChapterId
}
}
`;

View file

@ -0,0 +1,89 @@
import { gql, useQuery } from '@apollo/client';
import { BookChapterForm } from '@app/client/components/BookChapterForm';
import { useIdentity } from '@app/client/hooks/identity';
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';
export const Route = createFileRoute('/work/book/$bookId/chapter/new')({
component: RouteComponent,
beforeLoad: ({ context }) => {
if (!context.isAuthenticated) throw redirect({ to: '/' });
}
});
function RouteComponent() {
const { bookId: id } = Route.useParams();
const identity = useIdentity();
const navigate = useNavigate();
const { data } = useQuery<BookChapterNewQuery>(query, {
variables: { id }
});
const { update: chapterCreate } = useToastMutation<BookChapterNewCreateMutation>({
mutation,
successMsg: 'Chapter created successfully',
refetchQueries: [bookChapterListQuery, bookChapterNavigationQuery]
});
if (data == null) return <Skeleton />;
if (identity?.data.user.id !== data.bookOne.author.id)
throw redirect({ to: `/work/book/${id}/overview` });
return (
<BookChapterForm
bookId={id}
onChapterSubmit={async chapter => {
await chapterCreate({
variables: {
input: {
bookId: id,
...chapter
}
}
});
navigate({ to: `/work/book/${id}/overview` });
}}
/>
);
}
const query = gql`
query BookChapterNew($id: ID!) {
bookOne(id: $id) {
id
author {
id
}
}
}
`;
const mutation = gql`
mutation BookChapterNewCreate($input: BookChapterCreateInput!) {
bookChapterCreate(input: $input) {
id
title
foreword
footnote
content
previousChapterId
nextChapterId
book {
id
updatedAt
wordsTotal
chaptersTotal
author {
id
}
}
}
}
`;

View file

@ -1,59 +1,56 @@
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 { 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 {
Heading,
HStack,
Skeleton,
SkeletonCircle,
SkeletonText,
Stack,
Text
} from '@chakra-ui/react';
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';
export const Route = createFileRoute('/work/book/$bookId')({
export const Route = createFileRoute('/work/book/$bookId/overview')({
component: RouteComponent
});
function RouteComponent() {
const { bookId: id } = Route.useParams();
const { data: bookData } = useQuery<BookOverviewQuery>(bookQuery, { variables: { id } });
const { data: bookData } = useQuery<BookOverviewQuery>(bookQuery, {
variables: { id }
});
const identity = useIdentity();
return (
<Stack width="100%" justifyContent="center" alignItems="center" padding={3}>
{bookData != null ? <BookOverview {...bookData.bookOne} /> : <BookOverviewSkeleton />}
{bookData != null ? (
<BookOverview identity={identity} book={bookData.bookOne} />
) : (
<BookOverviewSkeleton />
)}
</Stack>
);
}
function BookOverview({
id,
title,
description,
author,
progressStatus,
createdAt,
updatedAt,
wordsTotal,
chaptersTotal,
reviewsTotal,
ratingTotal
}: BookOne) {
identity,
book
}: {
readonly identity: IdentityContext | null;
readonly book: BookOne;
}) {
return (
<Stack width="80%" maxWidth="840px" gap={3}>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Heading color="colorPalette.400" size="2xl" overflow="auto">
{title}
{book.title}
</Heading>
</Stack>
@ -62,7 +59,7 @@ function BookOverview({
<Link>
<Text fontWeight="bold">by:</Text>
</Link>
<Link to={`/profile/${author.username}`}>
<Link to={`/profile/${book.author.username}`}>
<Text
color="colorPalette.400"
_hover={{
@ -70,46 +67,58 @@ function BookOverview({
textDecorationColor: 'colorPalette.700'
}}
>
{author.username}
{book.author.username}
</Text>
</Link>
</Stack>
<Stack direction="row">
<Text fontWeight="bold">created at:</Text>
<Text>{toFormattedDate({ value: createdAt })}</Text>
<Text>{toFormattedDate({ value: book.createdAt })}</Text>
</Stack>
<Stack direction="row">
<Text fontWeight="bold">last updated:</Text>
<Text>{toFormattedDate({ value: updatedAt })}</Text>
<Text>{toFormattedDate({ value: book.updatedAt })}</Text>
</Stack>
<Stack direction="row">
<Text fontWeight="bold">chapters:</Text>
<Text>{chaptersTotal}</Text>
<Text>{book.chaptersTotal}</Text>
</Stack>
<Stack direction="row">
<Text fontWeight="bold">words:</Text>
<Text>{wordsTotal}</Text>
<Text>{book.wordsTotal}</Text>
</Stack>
</Stack>
<Stack direction="row" justifyContent="left" alignItems="center">
<WorkingRatingBadge ratingTotal={ratingTotal} />
<WorkingReviewsBadge reviewsTotal={reviewsTotal} />
<WorkingProgressStatusBadge progressStatus={progressStatus} />
<WorkingRatingBadge ratingTotal={book.ratingTotal} />
<WorkingReviewsBadge reviewsTotal={book.reviewsTotal} />
<WorkingProgressStatusBadge progressStatus={book.progressStatus} />
</Stack>
<Text>{description}</Text>
<Text>{book.description}</Text>
<BookChapterList id={id} />
<BookChapterList
id={book.id}
identity={identity}
isAuthor={identity?.data.id === book.author.id}
/>
</Stack>
);
}
function BookChapterList({ id }: { readonly id: string }) {
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;
@ -129,7 +138,33 @@ function BookChapterList({ id }: { readonly id: string }) {
<Stack>
{data != null ? (
<>
<Stack>...chapters</Stack>
<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
@ -141,6 +176,33 @@ function BookChapterList({ id }: { readonly id: string }) {
/>
</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 />
@ -154,7 +216,6 @@ function BookOverviewSkeleton() {
<Stack width="80%" maxWidth="840px">
<Stack gap="6" maxWidth="100%">
<HStack width="full">
<SkeletonCircle size="10" />
<SkeletonText noOfLines={5} />
</HStack>
<Skeleton height="200px" />
@ -172,7 +233,7 @@ const bookQuery = gql`
${bookFragment}
`;
const bookChapterListQuery = gql`
export const bookChapterListQuery = gql`
query BookChapterList($input: BookChapterManyInput!) {
bookChapterMany(input: $input) {
items {