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() {
|
||||
const [workingSearchValue, setWorkingSearchValue] = useState<string>('');
|
||||
const [workingsListPage, setWorkingsListPage] = useState<number>(1);
|
||||
const [workingsListPageSize, setWorkingsListPageSize] = useState<number>(10);
|
||||
const [workingsListPageSize, setWorkingsListPageSize] = useState<number>(8);
|
||||
const startIndex = (workingsListPage - 1) * workingsListPageSize;
|
||||
|
||||
const { data } = usePaginatedQuery<BooksListQuery>({
|
||||
query,
|
||||
query: bookListQuery,
|
||||
input: {
|
||||
filter: {
|
||||
terms: workingSearchValue,
|
||||
|
|
@ -51,7 +51,7 @@ export function BookSearchList() {
|
|||
);
|
||||
}
|
||||
|
||||
const query = gql`
|
||||
export const bookListQuery = gql`
|
||||
query BooksList($input: BookManyInput!) {
|
||||
bookMany(input: $input) {
|
||||
items {
|
||||
|
|
|
|||
|
|
@ -20,21 +20,25 @@ import {
|
|||
} from '../types';
|
||||
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 (
|
||||
<>
|
||||
<Card.Root
|
||||
variant={'subtle'}
|
||||
width="100%"
|
||||
maxWidth="840px"
|
||||
minHeight="100%"
|
||||
flex={1}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
background={readOnly ? 'none' : undefined}
|
||||
>
|
||||
{/* <Card.Header>
|
||||
<Heading textAlign="center">Editor</Heading>
|
||||
</Card.Header> */}
|
||||
<Card.Body
|
||||
width="100%"
|
||||
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.Root>
|
||||
</>
|
||||
|
|
@ -65,91 +82,63 @@ type AlignType = (typeof TEXT_ALIGN_TYPES)[number];
|
|||
type ListType = (typeof LIST_TYPES)[number];
|
||||
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 renderLeaf = useCallback((props: RenderLeafProps) => <Leaf {...props} />, []);
|
||||
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 (
|
||||
<Slate editor={editor} initialValue={initialValue}>
|
||||
<Wrap justifyContent="center" alignItems="center" gap={1}>
|
||||
<MarkButton format="bold" icon="nf nf-oct-bold" />
|
||||
<MarkButton format="italic" icon="nf nf-oct-italic" />
|
||||
<MarkButton format="underline" icon="nf nf-md-format_underline" />
|
||||
<MarkButton format="code" icon="nf nf-oct-code" />
|
||||
<BlockButton format="heading-one" icon="nf nf-fa-heading" text="1" />
|
||||
<BlockButton format="heading-two" icon="nf nf-fa-heading" text="2" />
|
||||
<BlockButton format="block-quote" icon="nf nf-oct-quote" />
|
||||
<BlockButton format="numbered-list" icon="nf nf-fa-list_ol" />
|
||||
<BlockButton format="bulleted-list" icon="nf nf-fa-list_ul" />
|
||||
<BlockButton format="left" icon="nf nf-fa-align_left" />
|
||||
<BlockButton format="center" icon="nf nf-fa-align_center" />
|
||||
<BlockButton format="right" icon="nf nf-fa-align_right" />
|
||||
<BlockButton format="justify" icon="nf nf-fa-align_justify" />
|
||||
</Wrap>
|
||||
<Slate editor={editor} initialValue={initialValue} onChange={onChange}>
|
||||
{!readOnly && (
|
||||
<Wrap justifyContent="center" alignItems="center" gap={1}>
|
||||
<MarkButton format="bold" icon="nf nf-oct-bold" />
|
||||
<MarkButton format="italic" icon="nf nf-oct-italic" />
|
||||
<MarkButton format="underline" icon="nf nf-md-format_underline" />
|
||||
<MarkButton format="code" icon="nf nf-oct-code" />
|
||||
<BlockButton format="heading-one" icon="nf nf-fa-heading" text="1" />
|
||||
<BlockButton format="heading-two" icon="nf nf-fa-heading" text="2" />
|
||||
<BlockButton format="block-quote" icon="nf nf-oct-quote" />
|
||||
<BlockButton format="numbered-list" icon="nf nf-fa-list_ol" />
|
||||
<BlockButton format="bulleted-list" icon="nf nf-fa-list_ul" />
|
||||
<BlockButton format="left" icon="nf nf-fa-align_left" />
|
||||
<BlockButton format="center" icon="nf nf-fa-align_center" />
|
||||
<BlockButton format="right" icon="nf nf-fa-align_right" />
|
||||
<BlockButton format="justify" icon="nf nf-fa-align_justify" />
|
||||
</Wrap>
|
||||
)}
|
||||
<Stack
|
||||
width="100%"
|
||||
maxWidth="100%"
|
||||
height="100%"
|
||||
paddingX={3}
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
flex={1}
|
||||
>
|
||||
<Prose
|
||||
width="100%"
|
||||
minWidth="100%"
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
<Prose width="100%" minWidth="100%" height="100%" display="flex" flex={1}>
|
||||
<Editable
|
||||
className="slate-editor"
|
||||
style={{
|
||||
outline: 'none',
|
||||
minWidth: '100%',
|
||||
maxWidth: 'unset',
|
||||
maxHeight: '75vh',
|
||||
maxHeight: readOnly ? undefined : '60vh',
|
||||
height: '100%',
|
||||
padding: 0,
|
||||
overflowY: 'auto'
|
||||
}}
|
||||
renderElement={renderElement}
|
||||
renderLeaf={renderLeaf}
|
||||
readOnly={readOnly}
|
||||
placeholder=""
|
||||
spellCheck
|
||||
autoFocus
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { Badge, Box, Card, Heading, Stack, Text } from '@chakra-ui/react';
|
|||
import { Link } from '@tanstack/react-router';
|
||||
import { toFormattedDate } from '../helpers';
|
||||
import { BookOne } from '../interfaces';
|
||||
import { IconBadge } from './IconBadge';
|
||||
import { WorkingProgressStatusBadge } from './WorkingProgressStatusBadge';
|
||||
import { WorkingRatingBadge } from './WorkingRatingBadge';
|
||||
import { WorkingReviewsBadge } from './WorkingReviewsTotalBadge';
|
||||
|
|
@ -25,7 +24,7 @@ export function WorkingListCard({
|
|||
<Stack>
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Text as="div" minWidth={0}>
|
||||
<Link to={`/work/book/${id}`}>
|
||||
<Link to={`/work/book/${id}/overview`}>
|
||||
<Heading
|
||||
color="colorPalette.400"
|
||||
_hover={{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue