added chapter view, fixes, added ratings, added chapter updates/deletions
This commit is contained in:
parent
029b7e9353
commit
4801cf3a13
34 changed files with 1345 additions and 191 deletions
97
app/client/components/BookChapterCard.tsx
Normal file
97
app/client/components/BookChapterCard.tsx
Normal 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}
|
||||||
|
`;
|
||||||
121
app/client/components/BookChapterForm.tsx
Normal file
121
app/client/components/BookChapterForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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={{
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
});
|
});
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
});
|
});
|
||||||
|
|
|
||||||
104
app/client/pages/work/book/$bookId/chapter/$chapterId/edit.tsx
Normal file
104
app/client/pages/work/book/$bookId/chapter/$chapterId/edit.tsx
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
160
app/client/pages/work/book/$bookId/chapter/$chapterId/view.tsx
Normal file
160
app/client/pages/work/book/$bookId/chapter/$chapterId/view.tsx
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
89
app/client/pages/work/book/$bookId/chapter/new.tsx
Normal file
89
app/client/pages/work/book/$bookId/chapter/new.tsx
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 = {};
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
25
app/server/graphql/resolvers/bookChapterDelete.ts
Normal file
25
app/server/graphql/resolvers/bookChapterDelete.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
34
app/server/graphql/resolvers/bookChapterUpdateResolver.ts
Normal file
34
app/server/graphql/resolvers/bookChapterUpdateResolver.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
102
app/server/graphql/resolvers/ratingSetResolver.ts
Normal file
102
app/server/graphql/resolvers/ratingSetResolver.ts
Normal 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.');
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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])
|
||||||
}
|
}
|
||||||
|
|
|
||||||
26
app/server/service/book.ts
Normal file
26
app/server/service/book.ts
Normal 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
16
package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue