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

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

View file

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

View file

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

View file

@ -12,11 +12,11 @@ import { PaginatedListSkeleton } from './PaginatedListSkeleton';
export function BookSearchList() {
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 {

View file

@ -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

View file

@ -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={{