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,97 @@
import { gql } from '@apollo/client';
import { Box, Button, Card, Heading, Icon, Stack, Text } from '@chakra-ui/react';
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';
export function BookChapterCard({
bookId,
chapterId,
title,
isAuthor
}: {
readonly bookId: string;
readonly chapterId: string;
readonly title: string;
readonly isAuthor: boolean;
}) {
const { update: chapterDelete } = useToastMutation<BookChapterDeleteMutation>({
mutation,
successMsg: 'Chapter deleted successfully',
refetchQueries: [bookChapterListQuery]
});
return (
<Card.Root width="100%" variant="elevated">
<Card.Body paddingX={4} paddingY={2}>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Box>
<Text as="div" minWidth={0}>
<Link to={`/work/book/${bookId}/chapter/${chapterId}/view`}>
<Heading
size="sm"
color="colorPalette.400"
_hover={{
textDecoration: 'underline',
textDecorationColor: 'colorPalette.700'
}}
>
{title}
</Heading>
</Link>
</Text>
</Box>
<Stack direction="row" alignItems="center">
{isAuthor && (
<>
<Button
color="red.500"
variant="ghost"
padding={0}
_hover={{
backgroundColor: 'bg.error'
}}
onClick={async () => chapterDelete({ variables: { id: chapterId } })}
>
<Icon fontSize="xl">
<i className="nf nf-md-delete" />
</Icon>
</Button>
<Link to={`/work/book/${bookId}/chapter/${chapterId}/edit`}>
<Button
variant="ghost"
padding={0}
_hover={{
backgroundColor: 'bg.warning'
}}
>
<Icon color="yellow.500" fontSize="xl">
<i className="nf nf-fa-edit" />
</Icon>
</Button>
</Link>
</>
)}
<Link to={`/work/book/${bookId}/chapter/${chapterId}/view`}>
<Button variant="ghost" padding={0}>
<Icon fontSize="xl">
<i className="nf nf-fa-angle_right" />
</Icon>
</Button>
</Link>
</Stack>
</Stack>
</Card.Body>
</Card.Root>
);
}
const mutation = gql`
mutation BookChapterDelete($id: ID!) {
bookChapterDelete(id: $id) {
...Book
}
}
${bookFragment}
`;

View file

@ -0,0 +1,121 @@
import { Button } from '@app/client/components/ui/button';
import { Box, Input, Stack, Textarea } from '@chakra-ui/react';
import { valibotResolver } from '@hookform/resolvers/valibot';
import { Link } from '@tanstack/react-router';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { Descendant } from 'slate';
import * as v from 'valibot';
import { EditorSlate } from './EditorSlate';
import { Field } from './ui/field';
interface BookChapterEditInitialValues {
readonly title: string;
readonly foreword?: string;
readonly footnote?: string;
readonly content?: Descendant[];
}
interface BookChapterInput {
readonly title: string;
readonly foreword?: string;
readonly footnote?: string;
readonly content: Descendant[];
}
interface BookChapterFormValues {
readonly title: string;
readonly foreword: string;
readonly footnote: string;
}
export function BookChapterForm({
bookId,
initialValues,
onChapterSubmit
}: {
readonly bookId: string;
readonly initialValues?: BookChapterEditInitialValues | null;
readonly onChapterSubmit: (chapter: BookChapterInput) => Promise<void>;
}) {
const [content, setContent] = useState<Descendant[]>([]);
const schema = v.pipe(
v.object({
title: v.pipe(v.string(), v.nonEmpty('Title is required'), v.maxLength(60)),
foreword: v.pipe(v.string()),
footnote: v.pipe(v.string())
})
);
const {
register,
handleSubmit,
formState: { errors }
} = useForm<BookChapterFormValues>({
defaultValues: {
title: initialValues?.title,
foreword: initialValues?.foreword,
footnote: initialValues?.footnote
},
resolver: valibotResolver(schema)
});
const onSubmit = handleSubmit(async values =>
onChapterSubmit({
title: values.title,
foreword: values.foreword,
footnote: values.footnote,
content
})
);
return (
<form onSubmit={onSubmit}>
<Stack justifyContent="center" alignItems="center" minHeight="88vh">
<Stack maxWidth="840px" minHeight="80vh" justifyContent="center" alignItems="center">
<Stack width="100%" direction="row" justifyContent="space-between" alignItems="start">
<Box>
<Field invalid={errors.title != null} errorText={errors.title?.message}>
<Input {...register('title')} placeholder="Chapter title" />
</Field>
</Box>
<Stack direction="row">
<Button onClick={async () => onSubmit()}>Submit</Button>
<Link to={`/work/book/${bookId}/overview`}>
<Button>Go back</Button>
</Link>
</Stack>
</Stack>
<Field invalid={errors.foreword != null} errorText={errors.foreword?.message}>
<Textarea
color="fg.muted"
variant="subtle"
placeholder="Foreword"
resize="none"
maxLength={500}
{...register('foreword')}
/>
</Field>
<EditorSlate
initialValue={initialValues?.content}
onChange={values => setContent(values)}
/>
<Field invalid={errors.footnote != null} errorText={errors.footnote?.message}>
<Textarea
color="fg.muted"
variant="subtle"
placeholder="Footnote"
resize="none"
maxLength={500}
{...register('footnote')}
/>
</Field>
</Stack>
</Stack>
</form>
);
}

View file

@ -12,11 +12,11 @@ import { PaginatedListSkeleton } from './PaginatedListSkeleton';
export function BookSearchList() { export function BookSearchList() {
const [workingSearchValue, setWorkingSearchValue] = useState<string>(''); const [workingSearchValue, setWorkingSearchValue] = useState<string>('');
const [workingsListPage, setWorkingsListPage] = useState<number>(1); const [workingsListPage, setWorkingsListPage] = useState<number>(1);
const [workingsListPageSize, setWorkingsListPageSize] = useState<number>(10); const [workingsListPageSize, setWorkingsListPageSize] = useState<number>(8);
const startIndex = (workingsListPage - 1) * workingsListPageSize; const startIndex = (workingsListPage - 1) * workingsListPageSize;
const { data } = usePaginatedQuery<BooksListQuery>({ const { data } = usePaginatedQuery<BooksListQuery>({
query, query: bookListQuery,
input: { input: {
filter: { filter: {
terms: workingSearchValue, terms: workingSearchValue,
@ -51,7 +51,7 @@ export function BookSearchList() {
); );
} }
const query = gql` export const bookListQuery = gql`
query BooksList($input: BookManyInput!) { query BooksList($input: BookManyInput!) {
bookMany(input: $input) { bookMany(input: $input) {
items { items {

View file

@ -20,21 +20,25 @@ import {
} from '../types'; } from '../types';
import { Prose } from './ui/prose'; import { Prose } from './ui/prose';
export function EditorSlate() { export function EditorSlate({
initialValue,
onChange,
readOnly
}: {
readonly initialValue?: Descendant[];
readonly onChange?: (values: Descendant[]) => void;
readonly readOnly?: boolean;
}) {
return ( return (
<> <>
<Card.Root <Card.Root
variant={'subtle'} variant={'subtle'}
width="100%" width="100%"
maxWidth="840px"
minHeight="100%"
flex={1} flex={1}
justifyContent="center" justifyContent="center"
alignItems="center" alignItems="center"
background={readOnly ? 'none' : undefined}
> >
{/* <Card.Header>
<Heading textAlign="center">Editor</Heading>
</Card.Header> */}
<Card.Body <Card.Body
width="100%" width="100%"
css={{ css={{
@ -44,7 +48,20 @@ export function EditorSlate() {
} }
}} }}
> >
<EditorViewport /> <EditorViewport
initialValue={
initialValue ?? [
{
type: 'paragraph',
children: [{ text: '' }]
}
]
}
onChange={values => {
if (onChange != null) onChange(values);
}}
readOnly={readOnly}
/>
</Card.Body> </Card.Body>
</Card.Root> </Card.Root>
</> </>
@ -65,49 +82,22 @@ type AlignType = (typeof TEXT_ALIGN_TYPES)[number];
type ListType = (typeof LIST_TYPES)[number]; type ListType = (typeof LIST_TYPES)[number];
type CustomElementFormat = CustomElementType | AlignType | ListType; type CustomElementFormat = CustomElementType | AlignType | ListType;
function EditorViewport() { function EditorViewport({
initialValue,
onChange,
readOnly
}: {
readonly initialValue: Descendant[];
readonly onChange: (values: Descendant[]) => void;
readonly readOnly?: boolean;
}) {
const renderElement = useCallback((props: RenderElementProps) => <Element {...props} />, []); const renderElement = useCallback((props: RenderElementProps) => <Element {...props} />, []);
const renderLeaf = useCallback((props: RenderLeafProps) => <Leaf {...props} />, []); const renderLeaf = useCallback((props: RenderLeafProps) => <Leaf {...props} />, []);
const editor = useMemo(() => withHistory(withReact(createEditor())), []); const editor = useMemo(() => withHistory(withReact(createEditor())), []);
const initialValue: Descendant[] = [
{
type: 'paragraph',
children: [
{ text: 'This is editable ' },
{ text: 'rich', bold: true },
{ text: ' text, ' },
{ text: 'much', italic: true },
{ text: ' better than a ' },
{ text: '<textarea>', code: true },
{ text: '!' }
]
},
{
type: 'paragraph',
children: [
{
text: "Since it's rich text, you can do things like turn a selection of text "
},
{ text: 'bold', bold: true },
{
text: ', or add a semantically rendered block quote in the middle of the page, like this:'
}
]
},
{
type: 'block-quote',
children: [{ text: 'A wise quote.' }]
},
{
type: 'paragraph',
align: 'center',
children: [{ text: 'Try it out for yourself!' }]
}
];
return ( return (
<Slate editor={editor} initialValue={initialValue}> <Slate editor={editor} initialValue={initialValue} onChange={onChange}>
{!readOnly && (
<Wrap justifyContent="center" alignItems="center" gap={1}> <Wrap justifyContent="center" alignItems="center" gap={1}>
<MarkButton format="bold" icon="nf nf-oct-bold" /> <MarkButton format="bold" icon="nf nf-oct-bold" />
<MarkButton format="italic" icon="nf nf-oct-italic" /> <MarkButton format="italic" icon="nf nf-oct-italic" />
@ -123,33 +113,32 @@ function EditorViewport() {
<BlockButton format="right" icon="nf nf-fa-align_right" /> <BlockButton format="right" icon="nf nf-fa-align_right" />
<BlockButton format="justify" icon="nf nf-fa-align_justify" /> <BlockButton format="justify" icon="nf nf-fa-align_justify" />
</Wrap> </Wrap>
)}
<Stack <Stack
width="100%" width="100%"
maxWidth="100%" maxWidth="100%"
height="100%"
paddingX={3} paddingX={3}
display="flex" display="flex"
justifyContent="center" justifyContent="center"
alignItems="center" alignItems="center"
flex={1}
> >
<Prose <Prose width="100%" minWidth="100%" height="100%" display="flex" flex={1}>
width="100%"
minWidth="100%"
display="flex"
justifyContent="center"
alignItems="center"
>
<Editable <Editable
className="slate-editor" className="slate-editor"
style={{ style={{
outline: 'none', outline: 'none',
minWidth: '100%', minWidth: '100%',
maxWidth: 'unset', maxWidth: 'unset',
maxHeight: '75vh', maxHeight: readOnly ? undefined : '60vh',
height: '100%',
padding: 0, padding: 0,
overflowY: 'auto' overflowY: 'auto'
}} }}
renderElement={renderElement} renderElement={renderElement}
renderLeaf={renderLeaf} renderLeaf={renderLeaf}
readOnly={readOnly}
placeholder="" placeholder=""
spellCheck spellCheck
autoFocus autoFocus

View file

@ -2,7 +2,6 @@ import { Badge, Box, Card, Heading, Stack, Text } from '@chakra-ui/react';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { toFormattedDate } from '../helpers'; import { toFormattedDate } from '../helpers';
import { BookOne } from '../interfaces'; import { BookOne } from '../interfaces';
import { IconBadge } from './IconBadge';
import { WorkingProgressStatusBadge } from './WorkingProgressStatusBadge'; import { WorkingProgressStatusBadge } from './WorkingProgressStatusBadge';
import { WorkingRatingBadge } from './WorkingRatingBadge'; import { WorkingRatingBadge } from './WorkingRatingBadge';
import { WorkingReviewsBadge } from './WorkingReviewsTotalBadge'; import { WorkingReviewsBadge } from './WorkingReviewsTotalBadge';
@ -25,7 +24,7 @@ export function WorkingListCard({
<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}`}> <Link to={`/work/book/${id}/overview`}>
<Heading <Heading
color="colorPalette.400" color="colorPalette.400"
_hover={{ _hover={{

View file

@ -3,15 +3,21 @@ import {
DefaultContext, DefaultContext,
DocumentNode, DocumentNode,
FetchResult, FetchResult,
InternalRefetchQueriesInclude,
MaybeMasked, MaybeMasked,
MutationFunctionOptions, MutationFunctionOptions,
OperationVariables, OperationVariables,
Unmasked,
useMutation useMutation
} from '@apollo/client'; } from '@apollo/client';
import { toaster } from '../components/ui/toaster'; import { toaster } from '../components/ui/toaster';
interface UseToastMutationProps { interface UseToastMutationProps<T> {
mutation: DocumentNode; mutation: DocumentNode;
refetchQueries?:
| InternalRefetchQueriesInclude
| ((result: FetchResult<Unmasked<NoInfer<T>>>) => InternalRefetchQueriesInclude)
| undefined;
successMsg?: string; successMsg?: string;
} }
@ -23,13 +29,14 @@ interface UseToastMutationReturn<T> {
) => Promise<FetchResult<MaybeMasked<T>>>; ) => Promise<FetchResult<MaybeMasked<T>>>;
} }
export function useToastMutation<T>(props: UseToastMutationProps): UseToastMutationReturn<T> { export function useToastMutation<T>(props: UseToastMutationProps<T>): UseToastMutationReturn<T> {
const [update] = useMutation<T>(props.mutation, { const [update] = useMutation<T>(props.mutation, {
onCompleted: () => toaster.create({ description: props.successMsg, type: 'success' }), onCompleted: () => toaster.create({ description: props.successMsg, type: 'success' }),
onError: error => { onError: error => {
toaster.create({ description: `${error.message}`, type: 'error' }); toaster.create({ description: `${error.message}`, type: 'error' });
console.error(error); console.error(error);
} },
refetchQueries: props.refetchQueries
}); });
return { update }; return { update };

View file

@ -1,5 +1,6 @@
import { Icon, Input, InputGroup, Kbd, Menu, Separator, Stack, Text } from '@chakra-ui/react'; import { Icon, Kbd, Menu, Separator, Stack, Text } from '@chakra-ui/react';
import { createRootRouteWithContext, Outlet, Link } from '@tanstack/react-router'; import { createRootRouteWithContext, Link, Outlet, useNavigate } from '@tanstack/react-router';
import isHotkey from 'is-hotkey';
import React from 'react'; import React from 'react';
import { Avatar } from '../components/ui/avatar'; import { Avatar } from '../components/ui/avatar';
import { Button } from '../components/ui/button'; import { Button } from '../components/ui/button';
@ -29,9 +30,17 @@ export const Route = createRootRouteWithContext<RouterContext>()({
component: () => { component: () => {
const { logout } = useAuth(); const { logout } = useAuth();
const identity = useIdentity(); const identity = useIdentity();
const navigate = useNavigate();
return ( return (
<Stack padding={3}> <Stack
onKeyDown={e => {
if (isHotkey('mod+k', e)) {
navigate({ to: '/' });
}
}}
padding={3}
>
<Stack flexDirection="row" justifyContent="space-between" alignItems="center"> <Stack flexDirection="row" justifyContent="space-between" alignItems="center">
<Stack flexDirection="row" justifyContent="center" alignItems="center" gap={10}> <Stack flexDirection="row" justifyContent="center" alignItems="center" gap={10}>
<Link to="/"> <Link to="/">
@ -44,6 +53,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
</Text> </Text>
</Stack> </Stack>
</Link> </Link>
{/* <Stack hideBelow="md"> */} {/* <Stack hideBelow="md"> */}
{/* <Link to="/about"> */} {/* <Link to="/about"> */}
{/* <Icon color="colorPalette.500" fontSize="md"> */} {/* <Icon color="colorPalette.500" fontSize="md"> */}
@ -85,19 +95,29 @@ export const Route = createRootRouteWithContext<RouterContext>()({
</Stack> </Stack>
<Stack flexDirection="row" alignItems="center" gap={4}> <Stack flexDirection="row" alignItems="center" gap={4}>
<InputGroup <Stack
hideBelow="md" direction="row"
flex={1}
alignItems="center" alignItems="center"
startElement={ paddingY={1}
<Icon color="colorPalette.500"> paddingX={2}
gap={6}
border="sm"
borderColor="border"
borderRadius="md"
onClick={() => navigate({ to: '/' })}
>
<Stack direction="row" alignItems="center">
<Icon size="sm" color="colorPalette.500">
<i className="nf nf-oct-search" /> <i className="nf nf-oct-search" />
</Icon> </Icon>
}
endElement={<Kbd>⌘K</Kbd>} <Text fontSize="sm" color="fg.subtle">
> Search works...{' '}
<Input height={8} placeholder="Search works..." /> </Text>
</InputGroup> </Stack>
<Kbd>⌘K</Kbd>
</Stack>
<ColorModeButton /> <ColorModeButton />

View file

@ -9,7 +9,9 @@ export const Route = createFileRoute('/editor')({
function RouteComponent() { function RouteComponent() {
return ( return (
<Stack justifyContent="center" alignItems="center" minHeight="88vh"> <Stack justifyContent="center" alignItems="center" minHeight="88vh">
<Stack maxWidth="840px" justifyContent="center" alignItems="center" minHeight="88vh">
<EditorSlate /> <EditorSlate />
</Stack> </Stack>
</Stack>
); );
} }

View file

@ -1,4 +1,5 @@
import { gql } from '@apollo/client'; import { gql } from '@apollo/client';
import { bookListQuery } from '@app/client/components/BookSearchList';
import { useToastMutation } from '@app/client/hooks/useToastMutation'; import { useToastMutation } from '@app/client/hooks/useToastMutation';
import { Heading, Input, Stack, Textarea } from '@chakra-ui/react'; import { Heading, Input, Stack, Textarea } from '@chakra-ui/react';
import { Button } from '@client/components/ui/button'; import { Button } from '@client/components/ui/button';
@ -34,7 +35,8 @@ function NewBookForm() {
const navigate = useNavigate(); const navigate = useNavigate();
const { update: bookCreate } = useToastMutation<CreateNewBookMutation>({ const { update: bookCreate } = useToastMutation<CreateNewBookMutation>({
mutation: bookCreateMutation, mutation: bookCreateMutation,
successMsg: 'Book created successfully!' successMsg: 'Book created successfully!',
refetchQueries: [bookListQuery]
}); });
const schema = v.pipe( const schema = v.pipe(
@ -64,7 +66,7 @@ function NewBookForm() {
}); });
if (data != null) if (data != null)
navigate({ navigate({
to: `/work/book/${data.bookCreate.id}` to: `/work/book/${data.bookCreate.id}/overview`
}); });
})} })}
> >

View file

@ -3,7 +3,7 @@ import { Heading, Input, Stack, Text } from '@chakra-ui/react';
import { Button } from '@client/components/ui/button'; import { Button } from '@client/components/ui/button';
import { Field } from '@client/components/ui/field'; import { Field } from '@client/components/ui/field';
import { PasswordInput } from '@client/components/ui/password-input'; import { PasswordInput } from '@client/components/ui/password-input';
import { Session } from '@generated/types'; import { AuthSignUpMutation } from '@generated/types';
import { valibotResolver } from '@hookform/resolvers/valibot'; import { valibotResolver } from '@hookform/resolvers/valibot';
import { createFileRoute, redirect, useNavigate } from '@tanstack/react-router'; import { createFileRoute, redirect, useNavigate } from '@tanstack/react-router';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
@ -42,7 +42,7 @@ interface FormValues {
function SignUpForm() { function SignUpForm() {
const navigate = useNavigate(); const navigate = useNavigate();
const { update: signUp } = useToastMutation<Session>({ const { update: signUp } = useToastMutation<AuthSignUpMutation>({
mutation, mutation,
successMsg: 'Account created successfully' successMsg: 'Account created successfully'
}); });

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 { gql, useQuery } from '@apollo/client';
import { BookChapterCard } from '@app/client/components/BookChapterCard';
import { PaginatedListSkeleton } from '@app/client/components/PaginatedListSkeleton'; import { PaginatedListSkeleton } from '@app/client/components/PaginatedListSkeleton';
import { Pagination } from '@app/client/components/Pagination'; 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 { bookChapterFragment, 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 { usePaginatedQuery } from '@app/client/hooks/usePaginatedQuery';
import { BookOne } from '@app/client/interfaces'; import { BookOne } from '@app/client/interfaces';
import { import { Box, Heading, HStack, Icon, Skeleton, SkeletonText, Stack, Text } from '@chakra-ui/react';
Heading,
HStack,
Skeleton,
SkeletonCircle,
SkeletonText,
Stack,
Text
} from '@chakra-ui/react';
import { BookChapterListQuery, BookOverviewQuery } from '@generated/types'; import { BookChapterListQuery, BookOverviewQuery } from '@generated/types';
import { createFileRoute, Link } from '@tanstack/react-router'; import { createFileRoute, Link } from '@tanstack/react-router';
import { useState } from 'react'; import { useState } from 'react';
export const Route = createFileRoute('/work/book/$bookId')({ export const Route = createFileRoute('/work/book/$bookId/overview')({
component: RouteComponent component: RouteComponent
}); });
function RouteComponent() { function RouteComponent() {
const { bookId: id } = Route.useParams(); 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 ( return (
<Stack width="100%" justifyContent="center" alignItems="center" padding={3}> <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> </Stack>
); );
} }
function BookOverview({ function BookOverview({
id, identity,
title, book
description, }: {
author, readonly identity: IdentityContext | null;
progressStatus, readonly book: BookOne;
createdAt, }) {
updatedAt,
wordsTotal,
chaptersTotal,
reviewsTotal,
ratingTotal
}: BookOne) {
return ( return (
<Stack width="80%" maxWidth="840px" gap={3}> <Stack width="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">
{title} {book.title}
</Heading> </Heading>
</Stack> </Stack>
@ -62,7 +59,7 @@ function BookOverview({
<Link> <Link>
<Text fontWeight="bold">by:</Text> <Text fontWeight="bold">by:</Text>
</Link> </Link>
<Link to={`/profile/${author.username}`}> <Link to={`/profile/${book.author.username}`}>
<Text <Text
color="colorPalette.400" color="colorPalette.400"
_hover={{ _hover={{
@ -70,46 +67,58 @@ function BookOverview({
textDecorationColor: 'colorPalette.700' textDecorationColor: 'colorPalette.700'
}} }}
> >
{author.username} {book.author.username}
</Text> </Text>
</Link> </Link>
</Stack> </Stack>
<Stack direction="row"> <Stack direction="row">
<Text fontWeight="bold">created at:</Text> <Text fontWeight="bold">created at:</Text>
<Text>{toFormattedDate({ value: createdAt })}</Text> <Text>{toFormattedDate({ value: book.createdAt })}</Text>
</Stack> </Stack>
<Stack direction="row"> <Stack direction="row">
<Text fontWeight="bold">last updated:</Text> <Text fontWeight="bold">last updated:</Text>
<Text>{toFormattedDate({ value: updatedAt })}</Text> <Text>{toFormattedDate({ value: book.updatedAt })}</Text>
</Stack> </Stack>
<Stack direction="row"> <Stack direction="row">
<Text fontWeight="bold">chapters:</Text> <Text fontWeight="bold">chapters:</Text>
<Text>{chaptersTotal}</Text> <Text>{book.chaptersTotal}</Text>
</Stack> </Stack>
<Stack direction="row"> <Stack direction="row">
<Text fontWeight="bold">words:</Text> <Text fontWeight="bold">words:</Text>
<Text>{wordsTotal}</Text> <Text>{book.wordsTotal}</Text>
</Stack> </Stack>
</Stack> </Stack>
<Stack direction="row" justifyContent="left" alignItems="center"> <Stack direction="row" justifyContent="left" alignItems="center">
<WorkingRatingBadge ratingTotal={ratingTotal} /> <WorkingRatingBadge ratingTotal={book.ratingTotal} />
<WorkingReviewsBadge reviewsTotal={reviewsTotal} /> <WorkingReviewsBadge reviewsTotal={book.reviewsTotal} />
<WorkingProgressStatusBadge progressStatus={progressStatus} /> <WorkingProgressStatusBadge progressStatus={book.progressStatus} />
</Stack> </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> </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 [chaptersListPage, setChaptersListPage] = useState<number>(1);
const [chaptersListPageSize, setChaptersListPageSize] = useState<number>(10); const [chaptersListPageSize, setChaptersListPageSize] = useState<number>(10);
const startIndex = (chaptersListPage - 1) * chaptersListPageSize; const startIndex = (chaptersListPage - 1) * chaptersListPageSize;
@ -129,7 +138,33 @@ function BookChapterList({ id }: { readonly id: string }) {
<Stack> <Stack>
{data != null ? ( {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 && ( {data.bookChapterMany.total > chaptersListPageSize && (
<Stack justifyContent="center" alignItems="center"> <Stack justifyContent="center" alignItems="center">
<Pagination <Pagination
@ -141,6 +176,33 @@ function BookChapterList({ id }: { readonly id: string }) {
/> />
</Stack> </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 /> <PaginatedListSkeleton disableSearch />
@ -154,7 +216,6 @@ function BookOverviewSkeleton() {
<Stack width="80%" maxWidth="840px"> <Stack width="80%" maxWidth="840px">
<Stack gap="6" maxWidth="100%"> <Stack gap="6" maxWidth="100%">
<HStack width="full"> <HStack width="full">
<SkeletonCircle size="10" />
<SkeletonText noOfLines={5} /> <SkeletonText noOfLines={5} />
</HStack> </HStack>
<Skeleton height="200px" /> <Skeleton height="200px" />
@ -172,7 +233,7 @@ const bookQuery = gql`
${bookFragment} ${bookFragment}
`; `;
const bookChapterListQuery = gql` export const bookChapterListQuery = gql`
query BookChapterList($input: BookChapterManyInput!) { query BookChapterList($input: BookChapterManyInput!) {
bookChapterMany(input: $input) { bookChapterMany(input: $input) {
items { items {

View file

@ -18,7 +18,10 @@ import { Route as AboutImport } from './pages/about'
import { Route as IndexImport } from './pages/index' 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 WorkBookBookIdImport } from './pages/work/book/$bookId' import { Route as WorkBookBookIdOverviewImport } from './pages/work/book/$bookId/overview'
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'
// Create/Update Routes // Create/Update Routes
@ -64,9 +67,29 @@ const NewBookRoute = NewBookImport.update({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
} as any) } as any)
const WorkBookBookIdRoute = WorkBookBookIdImport.update({ const WorkBookBookIdOverviewRoute = WorkBookBookIdOverviewImport.update({
id: '/work/book/$bookId', id: '/work/book/$bookId/overview',
path: '/work/book/$bookId', path: '/work/book/$bookId/overview',
getParentRoute: () => rootRoute,
} as any)
const WorkBookBookIdChapterNewRoute = WorkBookBookIdChapterNewImport.update({
id: '/work/book/$bookId/chapter/new',
path: '/work/book/$bookId/chapter/new',
getParentRoute: () => rootRoute,
} as any)
const WorkBookBookIdChapterChapterIdViewRoute =
WorkBookBookIdChapterChapterIdViewImport.update({
id: '/work/book/$bookId/chapter/$chapterId/view',
path: '/work/book/$bookId/chapter/$chapterId/view',
getParentRoute: () => rootRoute,
} as any)
const WorkBookBookIdChapterChapterIdEditRoute =
WorkBookBookIdChapterChapterIdEditImport.update({
id: '/work/book/$bookId/chapter/$chapterId/edit',
path: '/work/book/$bookId/chapter/$chapterId/edit',
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
} as any) } as any)
@ -123,11 +146,32 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ProfileUsernameImport preLoaderRoute: typeof ProfileUsernameImport
parentRoute: typeof rootRoute parentRoute: typeof rootRoute
} }
'/work/book/$bookId': { '/work/book/$bookId/overview': {
id: '/work/book/$bookId' id: '/work/book/$bookId/overview'
path: '/work/book/$bookId' path: '/work/book/$bookId/overview'
fullPath: '/work/book/$bookId' fullPath: '/work/book/$bookId/overview'
preLoaderRoute: typeof WorkBookBookIdImport preLoaderRoute: typeof WorkBookBookIdOverviewImport
parentRoute: typeof rootRoute
}
'/work/book/$bookId/chapter/new': {
id: '/work/book/$bookId/chapter/new'
path: '/work/book/$bookId/chapter/new'
fullPath: '/work/book/$bookId/chapter/new'
preLoaderRoute: typeof WorkBookBookIdChapterNewImport
parentRoute: typeof rootRoute
}
'/work/book/$bookId/chapter/$chapterId/edit': {
id: '/work/book/$bookId/chapter/$chapterId/edit'
path: '/work/book/$bookId/chapter/$chapterId/edit'
fullPath: '/work/book/$bookId/chapter/$chapterId/edit'
preLoaderRoute: typeof WorkBookBookIdChapterChapterIdEditImport
parentRoute: typeof rootRoute
}
'/work/book/$bookId/chapter/$chapterId/view': {
id: '/work/book/$bookId/chapter/$chapterId/view'
path: '/work/book/$bookId/chapter/$chapterId/view'
fullPath: '/work/book/$bookId/chapter/$chapterId/view'
preLoaderRoute: typeof WorkBookBookIdChapterChapterIdViewImport
parentRoute: typeof rootRoute parentRoute: typeof rootRoute
} }
} }
@ -143,7 +187,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': typeof WorkBookBookIdRoute '/work/book/$bookId/overview': typeof WorkBookBookIdOverviewRoute
'/work/book/$bookId/chapter/new': typeof WorkBookBookIdChapterNewRoute
'/work/book/$bookId/chapter/$chapterId/edit': typeof WorkBookBookIdChapterChapterIdEditRoute
'/work/book/$bookId/chapter/$chapterId/view': typeof WorkBookBookIdChapterChapterIdViewRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
@ -154,7 +201,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': typeof WorkBookBookIdRoute '/work/book/$bookId/overview': typeof WorkBookBookIdOverviewRoute
'/work/book/$bookId/chapter/new': typeof WorkBookBookIdChapterNewRoute
'/work/book/$bookId/chapter/$chapterId/edit': typeof WorkBookBookIdChapterChapterIdEditRoute
'/work/book/$bookId/chapter/$chapterId/view': typeof WorkBookBookIdChapterChapterIdViewRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
@ -166,7 +216,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': typeof WorkBookBookIdRoute '/work/book/$bookId/overview': typeof WorkBookBookIdOverviewRoute
'/work/book/$bookId/chapter/new': typeof WorkBookBookIdChapterNewRoute
'/work/book/$bookId/chapter/$chapterId/edit': typeof WorkBookBookIdChapterChapterIdEditRoute
'/work/book/$bookId/chapter/$chapterId/view': typeof WorkBookBookIdChapterChapterIdViewRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
@ -179,7 +232,10 @@ export interface FileRouteTypes {
| '/register' | '/register'
| '/new/book' | '/new/book'
| '/profile/$username' | '/profile/$username'
| '/work/book/$bookId' | '/work/book/$bookId/overview'
| '/work/book/$bookId/chapter/new'
| '/work/book/$bookId/chapter/$chapterId/edit'
| '/work/book/$bookId/chapter/$chapterId/view'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/' | '/'
@ -189,7 +245,10 @@ export interface FileRouteTypes {
| '/register' | '/register'
| '/new/book' | '/new/book'
| '/profile/$username' | '/profile/$username'
| '/work/book/$bookId' | '/work/book/$bookId/overview'
| '/work/book/$bookId/chapter/new'
| '/work/book/$bookId/chapter/$chapterId/edit'
| '/work/book/$bookId/chapter/$chapterId/view'
id: id:
| '__root__' | '__root__'
| '/' | '/'
@ -199,7 +258,10 @@ export interface FileRouteTypes {
| '/register' | '/register'
| '/new/book' | '/new/book'
| '/profile/$username' | '/profile/$username'
| '/work/book/$bookId' | '/work/book/$bookId/overview'
| '/work/book/$bookId/chapter/new'
| '/work/book/$bookId/chapter/$chapterId/edit'
| '/work/book/$bookId/chapter/$chapterId/view'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
@ -211,7 +273,10 @@ export interface RootRouteChildren {
RegisterRoute: typeof RegisterRoute RegisterRoute: typeof RegisterRoute
NewBookRoute: typeof NewBookRoute NewBookRoute: typeof NewBookRoute
ProfileUsernameRoute: typeof ProfileUsernameRoute ProfileUsernameRoute: typeof ProfileUsernameRoute
WorkBookBookIdRoute: typeof WorkBookBookIdRoute WorkBookBookIdOverviewRoute: typeof WorkBookBookIdOverviewRoute
WorkBookBookIdChapterNewRoute: typeof WorkBookBookIdChapterNewRoute
WorkBookBookIdChapterChapterIdEditRoute: typeof WorkBookBookIdChapterChapterIdEditRoute
WorkBookBookIdChapterChapterIdViewRoute: typeof WorkBookBookIdChapterChapterIdViewRoute
} }
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
@ -222,7 +287,12 @@ const rootRouteChildren: RootRouteChildren = {
RegisterRoute: RegisterRoute, RegisterRoute: RegisterRoute,
NewBookRoute: NewBookRoute, NewBookRoute: NewBookRoute,
ProfileUsernameRoute: ProfileUsernameRoute, ProfileUsernameRoute: ProfileUsernameRoute,
WorkBookBookIdRoute: WorkBookBookIdRoute, WorkBookBookIdOverviewRoute: WorkBookBookIdOverviewRoute,
WorkBookBookIdChapterNewRoute: WorkBookBookIdChapterNewRoute,
WorkBookBookIdChapterChapterIdEditRoute:
WorkBookBookIdChapterChapterIdEditRoute,
WorkBookBookIdChapterChapterIdViewRoute:
WorkBookBookIdChapterChapterIdViewRoute,
} }
export const routeTree = rootRoute export const routeTree = rootRoute
@ -242,7 +312,10 @@ export const routeTree = rootRoute
"/register", "/register",
"/new/book", "/new/book",
"/profile/$username", "/profile/$username",
"/work/book/$bookId" "/work/book/$bookId/overview",
"/work/book/$bookId/chapter/new",
"/work/book/$bookId/chapter/$chapterId/edit",
"/work/book/$bookId/chapter/$chapterId/view"
] ]
}, },
"/": { "/": {
@ -266,8 +339,17 @@ export const routeTree = rootRoute
"/profile/$username": { "/profile/$username": {
"filePath": "profile/$username.tsx" "filePath": "profile/$username.tsx"
}, },
"/work/book/$bookId": { "/work/book/$bookId/overview": {
"filePath": "work/book/$bookId.tsx" "filePath": "work/book/$bookId/overview.tsx"
},
"/work/book/$bookId/chapter/new": {
"filePath": "work/book/$bookId/chapter/new.tsx"
},
"/work/book/$bookId/chapter/$chapterId/edit": {
"filePath": "work/book/$bookId/chapter/$chapterId/edit.tsx"
},
"/work/book/$bookId/chapter/$chapterId/view": {
"filePath": "work/book/$bookId/chapter/$chapterId/view.tsx"
} }
} }
} }

View file

@ -13,6 +13,9 @@ import { UserOneResolver } from './resolvers/userOneResolver';
import { LoginResolver } from './resolvers/auth/login'; import { LoginResolver } from './resolvers/auth/login';
import { LogoutResolver } from './resolvers/auth/logout'; import { LogoutResolver } from './resolvers/auth/logout';
import { SignUpResolver } from './resolvers/auth/signUp'; import { SignUpResolver } from './resolvers/auth/signUp';
import { BookChapterCreateResolver } from './resolvers/bookChapterCreateResolver';
import { BookChapterDeleteResolver } from './resolvers/bookChapterDelete';
import { BookChapterUpdateResolver } from './resolvers/bookChapterUpdateResolver';
import { BookCreateResolver } from './resolvers/bookCreateResolver'; import { BookCreateResolver } from './resolvers/bookCreateResolver';
export const Query: QueryResolvers = { export const Query: QueryResolvers = {
@ -28,7 +31,10 @@ export const Mutation: MutationResolvers = {
signUp: SignUpResolver, signUp: SignUpResolver,
login: LoginResolver, login: LoginResolver,
logout: LogoutResolver, logout: LogoutResolver,
bookCreate: BookCreateResolver bookCreate: BookCreateResolver,
bookChapterCreate: BookChapterCreateResolver,
bookChapterDelete: BookChapterDeleteResolver,
bookChapterUpdate: BookChapterUpdateResolver
}; };
export const Subscription = {}; export const Subscription = {};

View file

@ -0,0 +1,38 @@
import { countWords, ensureIdentity } from '@app/server/helpers';
import { IdentityContext } from '@app/server/identity';
import { prisma, uuid } from '@app/server/prisma/client';
import { updateBookStats } from '@app/server/service/book';
import { BookChapter, MutationBookChapterCreateArgs } from '@generated/types';
import { toBookChapterSchema } from './mappers/bookChapterMapper';
export async function BookChapterCreateResolver(
_: any,
{ input }: MutationBookChapterCreateArgs,
{ req, res, identity }: IdentityContext
): Promise<BookChapter> {
ensureIdentity(identity);
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 chapterCount = await prisma.bookChapter.count({ where: { bookId: input.bookId } });
const model = await prisma.bookChapter.create({
data: {
id: uuid(),
bookId: input.bookId,
title: input.title,
foreword: input.foreword,
footnote: input.footnote,
content: input.content,
wordsTotal: countWords(input.content),
sequenceNumber: chapterCount + 1
}
});
await updateBookStats(input.bookId);
return toBookChapterSchema(model, { req, res, identity });
}

View file

@ -0,0 +1,25 @@
import { ensureIdentity } from '@app/server/helpers';
import { IdentityContext } from '@app/server/identity';
import { prisma } from '@app/server/prisma/client';
import { updateBookStats } from '@app/server/service/book';
import { Book, MutationBookChapterDeleteArgs } from '@generated/types';
import { toBookSchema } from './mappers/bookMapper';
export async function BookChapterDeleteResolver(
_: any,
{ id }: MutationBookChapterDeleteArgs,
{ req, res, identity }: IdentityContext
): Promise<Book> {
ensureIdentity(identity);
const model = await prisma.bookChapter.findUniqueOrThrow({
where: { id },
select: { 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 updateBookStats(model.book.id);
const book = await prisma.book.findUniqueOrThrow({ where: { id: model.book.id } });
return toBookSchema(book, { req, res, identity });
}

View file

@ -7,7 +7,7 @@ import { toBookChapterSchema } from './mappers/bookChapterMapper';
export async function BookChapterManyResolver( export async function BookChapterManyResolver(
_: any, _: any,
{ input }: QueryBookChapterManyArgs, { input }: QueryBookChapterManyArgs,
{ identity }: IdentityContext { req, res, identity }: IdentityContext
): Promise<BookChapterManyOutput> { ): Promise<BookChapterManyOutput> {
const { bookId, filter } = input; const { bookId, filter } = input;
const terms = filter.terms?.toLowerCase(); const terms = filter.terms?.toLowerCase();
@ -19,11 +19,14 @@ export async function BookChapterManyResolver(
const items = await prisma.bookChapter.findMany({ const items = await prisma.bookChapter.findMany({
where: bookChapterWhere, where: bookChapterWhere,
skip: filter.offset ?? 0, skip: filter.offset ?? 0,
take: filter.size ?? 10 take: filter.size ?? 10,
orderBy: { sequenceNumber: 'asc' }
}); });
return { return {
items: await Promise.all(items.map(value => toBookChapterSchema(value))), items: await Promise.all(
items.map(value => toBookChapterSchema(value, { req, res, identity }))
),
total: items.length total: items.length
}; };
} }

View file

@ -6,12 +6,12 @@ import { toBookChapterSchema } from './mappers/bookChapterMapper';
export async function BookChapterOneResolver( export async function BookChapterOneResolver(
_: any, _: any,
{ id }: QueryBookChapterOneArgs, { id }: QueryBookChapterOneArgs,
{ identity }: IdentityContext { req, res, identity }: IdentityContext
): Promise<BookChapter> { ): Promise<BookChapter> {
const model = await prisma.bookChapter.findUniqueOrThrow({ const model = await prisma.bookChapter.findUniqueOrThrow({
where: { where: {
id id
} }
}); });
return toBookChapterSchema(model); return toBookChapterSchema(model, { req, res, identity });
} }

View file

@ -0,0 +1,34 @@
import { countWords, ensureIdentity } from '@app/server/helpers';
import { IdentityContext } from '@app/server/identity';
import { prisma } from '@app/server/prisma/client';
import { updateBookStats } from '@app/server/service/book';
import { BookChapter, MutationBookChapterUpdateArgs } from '@generated/types';
import { toBookChapterSchema } from './mappers/bookChapterMapper';
export async function BookChapterUpdateResolver(
_: any,
{ input }: MutationBookChapterUpdateArgs,
{ req, res, identity }: IdentityContext
): Promise<BookChapter> {
ensureIdentity(identity);
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({
where: { id: input.id },
data: {
title: input.title,
foreword: input.foreword,
footnote: input.footnote,
content: input.content,
wordsTotal: countWords(input.content)
}
});
await updateBookStats(model.bookId);
return toBookChapterSchema(model, { req, res, identity });
}

View file

@ -7,11 +7,10 @@ import { toBookSchema } from './mappers/bookMapper';
export async function BookCreateResolver( export async function BookCreateResolver(
_: any, _: any,
{ input }: MutationBookCreateArgs, { input }: MutationBookCreateArgs,
{ identity }: IdentityContext { req, res, identity }: IdentityContext
): Promise<Book> { ): Promise<Book> {
ensureIdentity(identity); ensureIdentity(identity);
console.log(input.title.length);
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.description.length > 900) throw new Error('Description is too long.'); if (input.description.length > 900) throw new Error('Description is too long.');
@ -23,5 +22,5 @@ export async function BookCreateResolver(
authorId: identity.id authorId: identity.id
} }
}); });
return toBookSchema(model); return toBookSchema(model, { req, res, identity });
} }

View file

@ -7,7 +7,7 @@ import { toBookSchema } from './mappers/bookMapper';
export async function BookManyResolver( export async function BookManyResolver(
_: any, _: any,
{ input }: QueryBookManyArgs, { input }: QueryBookManyArgs,
{ identity }: IdentityContext { req, res, identity }: IdentityContext
): Promise<BookManyOutput> { ): Promise<BookManyOutput> {
const { filter } = input; const { filter } = input;
const terms = filter.terms?.toLowerCase(); const terms = filter.terms?.toLowerCase();
@ -20,11 +20,14 @@ export async function BookManyResolver(
const items = await prisma.book.findMany({ const items = await prisma.book.findMany({
where: bookWhere, where: bookWhere,
skip: filter.offset ?? 0, skip: filter.offset ?? 0,
take: filter.size ?? 10 take: filter.size ?? 10,
orderBy: {
createdAt: 'desc'
}
}); });
return { return {
items: await Promise.all(items.map(value => toBookSchema(value))), items: await Promise.all(items.map(value => toBookSchema(value, { req, res, identity }))),
total: await prisma.book.count({ where: bookWhere }) total: await prisma.book.count({ where: bookWhere })
}; };
} }

View file

@ -6,12 +6,12 @@ import { toBookSchema } from './mappers/bookMapper';
export async function BookOneResolver( export async function BookOneResolver(
_: any, _: any,
{ id }: QueryBookOneArgs, { id }: QueryBookOneArgs,
{ identity }: IdentityContext { req, res, identity }: IdentityContext
): Promise<Book> { ): Promise<Book> {
const model = await prisma.book.findUniqueOrThrow({ const model = await prisma.book.findUniqueOrThrow({
where: { where: {
id id
} }
}); });
return toBookSchema(model); return toBookSchema(model, { req, res, identity });
} }

View file

@ -1,9 +1,15 @@
import { call } from '@app/common'; import { call } from '@app/common';
import { ensureRatingType } from '@app/server/helpers';
import { IdentityContext } from '@app/server/identity';
import { prisma } from '@app/server/prisma/client'; import { prisma } from '@app/server/prisma/client';
import { BookChapter as BookChapterSchema } from '@generated/types'; import { BookChapter as BookChapterSchema, RatingType } from '@generated/types';
import { BookChapter } from '@prisma/client'; import { BookChapter } from '@prisma/client';
import { toBookSchema } from './bookMapper';
export async function toBookChapterSchema(model: BookChapter): Promise<BookChapterSchema> { export async function toBookChapterSchema(
model: BookChapter,
{ req, res, identity }: IdentityContext
): Promise<BookChapterSchema> {
return { return {
id: model.id, id: model.id,
title: model.title, title: model.title,
@ -11,7 +17,44 @@ export async function toBookChapterSchema(model: BookChapter): Promise<BookChapt
updatedAt: model.createdAt.toISOString(), updatedAt: model.createdAt.toISOString(),
foreword: model.foreword, foreword: model.foreword,
footnote: model.footnote, footnote: model.footnote,
content: model.content,
sequenceNumber: model.sequenceNumber, sequenceNumber: model.sequenceNumber,
previousChapterId: await call(async () => {
const isFirst = model.sequenceNumber === 1;
if (isFirst) return null;
const previousChapter = await prisma.bookChapter.findUniqueOrThrow({
where: {
bookId_sequenceNumber: {
bookId: model.bookId,
sequenceNumber: model.sequenceNumber - 1
}
},
select: {
id: true
}
});
return previousChapter.id;
}),
nextChapterId: await call(async () => {
const book = await prisma.book.findUniqueOrThrow({
where: { id: model.bookId },
select: { chaptersTotal: true }
});
const isLast = model.sequenceNumber === book.chaptersTotal;
if (isLast) return null;
const nextChapter = await prisma.bookChapter.findUniqueOrThrow({
where: {
bookId_sequenceNumber: {
bookId: model.bookId,
sequenceNumber: model.sequenceNumber + 1
}
},
select: { id: true }
});
return nextChapter.id;
}),
reviewsTotal: await call(async () => { reviewsTotal: await call(async () => {
const reviewsCount = await prisma.review.count({ const reviewsCount = await prisma.review.count({
where: { where: {
@ -19,6 +62,28 @@ export async function toBookChapterSchema(model: BookChapter): Promise<BookChapt
} }
}); });
return reviewsCount; return reviewsCount;
}),
book: await call(async () => {
const book = await prisma.book.findUniqueOrThrow({
where: {
id: model.bookId
}
});
return toBookSchema(book, { req, res, identity });
}),
personalRating: await call(async () => {
if (identity == null) return RatingType.None;
const rating = await prisma.rating.findUnique({
where: {
userId_bookChapterId: {
userId: identity.id,
bookChapterId: model.id
}
}
});
if (rating == null) return RatingType.None;
ensureRatingType(rating.type);
return rating.type;
}) })
}; };
} }

View file

@ -1,14 +1,20 @@
import { assertUnreachable } from '@app/client/helpers'; import { assertUnreachable } from '@app/client/helpers';
import { call } from '@app/common'; import { call } from '@app/common';
import { ensureRatingType } from '@app/server/helpers';
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,
WorkingProgressStatus as WorkingProgressStatusSchema WorkingProgressStatus as WorkingProgressStatusSchema
} from '@generated/types'; } from '@generated/types';
import { Book, WorkingProgressStatus } from '@prisma/client'; import { Book, WorkingProgressStatus } from '@prisma/client';
import { toUserSchema } from './userMapper'; import { toUserSchema } from './userMapper';
export async function toBookSchema(model: Book): Promise<BookSchema> { export async function toBookSchema(
model: Book,
{ identity }: IdentityContext
): Promise<BookSchema> {
return { return {
id: model.id, id: model.id,
title: model.title, title: model.title,
@ -48,6 +54,20 @@ export async function toBookSchema(model: Book): Promise<BookSchema> {
} }
}); });
return reviewsCount; return reviewsCount;
}),
personalRating: await call(async () => {
if (identity == null) return RatingType.None;
const rating = await prisma.rating.findUnique({
where: {
userId_bookChapterId: {
userId: identity.id,
bookChapterId: model.id
}
}
});
if (rating == null) return RatingType.None;
ensureRatingType(rating.type);
return rating.type;
}) })
}; };
} }

View file

@ -0,0 +1,102 @@
import { ensureIdentity } from '@app/server/helpers';
import { IdentityContext } from '@app/server/identity';
import { prisma, uuid } from '@app/server/prisma/client';
import { MutationRatingSetArgs, RatingType } from '@generated/types';
export async function BookCreateResolver(
_: any,
{ input }: MutationRatingSetArgs,
{ identity }: IdentityContext
): Promise<void> {
ensureIdentity(identity);
if (input.bookId != null) {
const rating = await prisma.rating.findUnique({
where: {
userId_bookId: {
userId: identity.id,
bookId: input.bookId
}
}
});
if (rating != null && input.ratingType !== RatingType.None) {
await prisma.rating.update({
where: { id: rating.id },
data: {
type: input.ratingType
}
});
return;
}
if (input.ratingType === RatingType.None) {
await prisma.rating.delete({
where: {
userId_bookId: {
userId: identity.id,
bookId: input.bookId
}
}
});
return;
}
await prisma.rating.create({
data: {
id: uuid(),
userId: identity.id,
bookId: input.bookId,
type: input.ratingType
}
});
return;
}
if (input.bookChapterId != null) {
const rating = await prisma.rating.findUnique({
where: {
userId_bookChapterId: {
userId: identity.id,
bookChapterId: input.bookChapterId
}
}
});
if (rating != null && input.ratingType !== RatingType.None) {
await prisma.rating.update({
where: { id: rating.id },
data: {
type: input.ratingType
}
});
return;
}
if (input.ratingType === RatingType.None) {
await prisma.rating.delete({
where: {
userId_bookChapterId: {
userId: identity.id,
bookChapterId: input.bookChapterId
}
}
});
return;
}
await prisma.rating.create({
data: {
id: uuid(),
userId: identity.id,
bookChapterId: input.bookChapterId,
type: input.ratingType
}
});
return;
}
throw new Error('Invalid Rating id input.');
}

View file

@ -1,4 +1,5 @@
scalar Void scalar Void
scalar JSON
type Query { type Query {
identity: Identity! identity: Identity!
@ -15,6 +16,13 @@ type Mutation {
login(input: LoginInput!): Session! login(input: LoginInput!): Session!
logout: Void logout: Void
bookCreate(input: BookCreateInput!): Book! bookCreate(input: BookCreateInput!): Book!
bookChapterCreate(input: BookChapterCreateInput!): BookChapter!
bookChapterUpdate(input: BookChapterUpdateInput!): BookChapter!
bookChapterDelete(id: ID!): Book!
reviewCreate(input: ReviewCreateInput!): Review!
reviewUpdate(input: ReviewUpdateInput!): Review!
reviewDelete(id: ID!): Void
ratingSet(input: RatingSetInput!): Void
} }
input SignUpInput { input SignUpInput {
@ -43,6 +51,7 @@ type Identity {
type User { type User {
id: ID! id: ID!
username: String! username: String!
description: String
createdAt: String! createdAt: String!
updatedAt: String! updatedAt: String!
} }
@ -58,13 +67,6 @@ input BookCreateInput {
description: String! description: String!
} }
input BookChapterCreateInput {
title: String!
content: String!
foreword: String
footnote: String
}
input BookManyInput { input BookManyInput {
filter: PaginationFilterInput! filter: PaginationFilterInput!
} }
@ -92,6 +94,7 @@ type Book {
chaptersTotal: Int! chaptersTotal: Int!
ratingTotal: Int! ratingTotal: Int!
reviewsTotal: Int! reviewsTotal: Int!
personalRating: RatingType!
} }
type BookChapter { type BookChapter {
@ -99,10 +102,31 @@ type BookChapter {
title: String! title: String!
createdAt: String! createdAt: String!
updatedAt: String! updatedAt: String!
foreword: String! foreword: String
footnote: String! footnote: String
content: JSON!
sequenceNumber: Int! sequenceNumber: Int!
reviewsTotal: Int! reviewsTotal: Int!
previousChapterId: String
nextChapterId: String
book: Book!
personalRating: RatingType!
}
input BookChapterCreateInput {
bookId: ID!
title: String!
foreword: String
footnote: String
content: JSON!
}
input BookChapterUpdateInput {
id: ID!
title: String!
foreword: String
footnote: String
content: JSON!
} }
input BookChapterManyInput { input BookChapterManyInput {
@ -122,6 +146,17 @@ type Review {
ratingTotal: Int! ratingTotal: Int!
} }
input ReviewCreateInput {
bookId: ID
chapterId: ID
text: String!
}
input ReviewUpdateInput {
id: ID!
text: String!
}
input ReviewManyInput { input ReviewManyInput {
bookId: String bookId: String
bookChapterId: String bookChapterId: String
@ -132,3 +167,15 @@ type ReviewManyOutput {
items: [Review!]! items: [Review!]!
total: Int! total: Int!
} }
input RatingSetInput {
bookId: String
bookChapterId: String
ratingType: RatingType!
}
enum RatingType {
up
down
none
}

View file

@ -1,3 +1,5 @@
import { RatingType as RatingTypeSchema } from '@generated/types';
import { Descendant, Text } from 'slate';
import { IdentityContext } from './identity'; import { IdentityContext } from './identity';
export function ensureIdentity( export function ensureIdentity(
@ -5,3 +7,28 @@ export function ensureIdentity(
): asserts identity is NonNullable<IdentityContext['identity']> { ): asserts identity is NonNullable<IdentityContext['identity']> {
if (identity == null) throw new Error('Unauthorized.'); if (identity == null) throw new Error('Unauthorized.');
} }
export function countWords(descendants: Descendant[]): number {
let wordCount = 0;
function traverse(nodes: Descendant[]) {
for (const node of nodes) {
if (Text.isText(node)) {
const words = node.text.trim().split(/\s+/).filter(Boolean);
wordCount += words.length;
} else if ('children' in node) {
traverse(node.children);
}
}
}
traverse(descendants);
return wordCount;
}
export function ensureRatingType(value: string): asserts value is RatingTypeSchema {
if (value === RatingTypeSchema.Up) return;
if (value === RatingTypeSchema.Down) return;
throw new Error('Invalid rating type');
}

View file

@ -6,6 +6,7 @@ import cors from 'cors';
import 'dotenv/config'; import 'dotenv/config';
import express from 'express'; import express from 'express';
import fs from 'fs'; import fs from 'fs';
import { GraphQLJSON } from 'graphql-scalars';
import https from 'https'; import https from 'https';
import path from 'path'; import path from 'path';
import ViteExpress from 'vite-express'; import ViteExpress from 'vite-express';
@ -26,6 +27,7 @@ const server = https.createServer({ key, cert }, app);
const typeDefs = fs.readFileSync(path.join(__dirname, 'graphql', 'schema.graphql'), 'utf8'); const typeDefs = fs.readFileSync(path.join(__dirname, 'graphql', 'schema.graphql'), 'utf8');
const resolvers = { const resolvers = {
JSON: GraphQLJSON,
Query, Query,
Mutation Mutation
// Subscription, // Subscription,

View file

@ -19,6 +19,8 @@ model User {
refreshToken String? @unique @db.VarChar(255) refreshToken String? @unique @db.VarChar(255)
description String? @db.VarChar(500)
isDarkMode Boolean @default(true) isDarkMode Boolean @default(true)
books Book[] books Book[]
@ -47,6 +49,8 @@ model Book {
authorId String authorId String
wordsTotal Int @default(0) wordsTotal Int @default(0)
chaptersTotal Int @default(0)
chapters BookChapter[] chapters BookChapter[]
reviews Review[] reviews Review[]
@ -59,11 +63,14 @@ model BookChapter {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
sequenceNumber Int @default(1) sequenceNumber Int
title String @db.VarChar(255) title String @db.VarChar(255)
foreword String @db.MediumText
footnote String @db.MediumText foreword String? @db.MediumText
footnote String? @db.MediumText
content Json @db.Json
wordsTotal Int @default(0) wordsTotal Int @default(0)
@ -72,6 +79,8 @@ model BookChapter {
reviews Review[] reviews Review[]
ratings Rating[] ratings Rating[]
@@unique([bookId, sequenceNumber])
} }
model Review { model Review {
@ -113,4 +122,7 @@ model Rating {
book Book? @relation(fields: [bookId], references: [id]) book Book? @relation(fields: [bookId], references: [id])
bookId String? bookId String?
@@unique([userId, bookId])
@@unique([userId, bookChapterId])
} }

View file

@ -0,0 +1,26 @@
import { prisma } from '@app/server/prisma/client';
export async function updateBookStats(id: string) {
const bookWordsTotal = await prisma.bookChapter.aggregate({
where: {
bookId: id
},
_sum: {
wordsTotal: true
}
});
const chaptersTotal = await prisma.bookChapter.count({
where: {
bookId: id
}
});
return prisma.book.update({
where: { id },
data: {
wordsTotal: bookWordsTotal._sum.wordsTotal != null ? bookWordsTotal._sum.wordsTotal : 0,
chaptersTotal
}
});
}

16
package-lock.json generated
View file

@ -32,6 +32,7 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^4.21.2", "express": "^4.21.2",
"graphql": "^16.9.0", "graphql": "^16.9.0",
"graphql-scalars": "^1.24.2",
"is-hotkey": "^0.2.0", "is-hotkey": "^0.2.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"next-themes": "^0.4.4", "next-themes": "^0.4.4",
@ -10901,6 +10902,21 @@
"graphql": "14 - 16" "graphql": "14 - 16"
} }
}, },
"node_modules/graphql-scalars": {
"version": "1.24.2",
"resolved": "https://registry.npmjs.org/graphql-scalars/-/graphql-scalars-1.24.2.tgz",
"integrity": "sha512-FoZ11yxIauEnH0E5rCUkhDXHVn/A6BBfovJdimRZCQlFCl+h7aVvarKmI15zG4VtQunmCDdqdtNs6ixThy3uAg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.5.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0"
}
},
"node_modules/graphql-tag": { "node_modules/graphql-tag": {
"version": "2.12.6", "version": "2.12.6",
"resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.6.tgz", "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.6.tgz",

View file

@ -20,11 +20,6 @@
"@chakra-ui/toast": "^7.0.2", "@chakra-ui/toast": "^7.0.2",
"@coolbytes/editorjs-link": "^1.0.0", "@coolbytes/editorjs-link": "^1.0.0",
"@deftomat/opinionated": "^1.1.3", "@deftomat/opinionated": "^1.1.3",
"@editorjs/editorjs": "^2.30.8",
"@editorjs/header": "^2.8.8",
"@editorjs/link": "^2.6.2",
"@editorjs/list": "^2.0.8",
"@editorjs/quote": "^2.7.6",
"@emotion/react": "^11.13.5", "@emotion/react": "^11.13.5",
"@hookform/resolvers": "^3.9.1", "@hookform/resolvers": "^3.9.1",
"@prisma/client": "^6.4.1", "@prisma/client": "^6.4.1",
@ -38,6 +33,7 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^4.21.2", "express": "^4.21.2",
"graphql": "^16.9.0", "graphql": "^16.9.0",
"graphql-scalars": "^1.24.2",
"is-hotkey": "^0.2.0", "is-hotkey": "^0.2.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"next-themes": "^0.4.4", "next-themes": "^0.4.4",