refactor, added slate, added editor, added book

This commit is contained in:
lnn0q 2025-05-28 23:25:46 +03:00
parent bc250e73bb
commit 029b7e9353
33 changed files with 714 additions and 496 deletions

View file

@ -1,16 +1,18 @@
import { gql } from '@apollo/client';
import { Skeleton, Stack } from '@chakra-ui/react';
import { Stack } from '@chakra-ui/react';
import { BooksListQuery } from '@generated/types';
import { useState } from 'react';
import { Pagination } from '../components/Pagination';
import { WorkingList } from '../components/WorkingList';
import { WorkingListSearch } from '../components/WorkingListSearch';
import { ListSearch } from '../components/ListSearch';
import { bookFragment } from '../fragments';
import { usePaginatedQuery } from '../hooks/usePaginatedQuery';
import { PaginatedListSkeleton } from './PaginatedListSkeleton';
export function BookSearchList() {
const [workingSearchValue, setWorkingSearchValue] = useState<string>('');
const [workingsListPage, setWorkingsListPage] = useState<number>(1);
const [workingsListPageSize, setWorkingsListPageSize] = useState<number>(2);
const [workingsListPageSize, setWorkingsListPageSize] = useState<number>(10);
const startIndex = (workingsListPage - 1) * workingsListPageSize;
const { data } = usePaginatedQuery<BooksListQuery>({
@ -26,35 +28,24 @@ export function BookSearchList() {
return (
<>
<ListSearch placeholder="Search works..." onSubmit={value => setWorkingSearchValue(value)} />
{data != null ? (
<>
<WorkingListSearch
onSubmit={value => {
setWorkingSearchValue(value);
}}
/>
<WorkingList workingsList={[...data.bookMany.items]} />
{data.bookMany.items.length !== 0 && (
<Pagination
total={data.bookMany.total}
page={workingsListPage}
setPage={setWorkingsListPage}
pageSize={workingsListPageSize}
/>
{data.bookMany.total > workingsListPageSize && (
<Stack justifyContent="center" alignItems="center">
<Pagination
total={data.bookMany.total}
page={workingsListPage}
setPage={setWorkingsListPage}
pageSize={workingsListPageSize}
setPageSize={setWorkingsListPageSize}
/>
</Stack>
)}
</>
) : (
<>
<Stack direction="row" justifyContent="center" alignItems="center" gap={2}>
<Skeleton height={10} width="80%" />
<Skeleton height={10} width="calc(20% - 6px)" />
</Stack>
<Skeleton height={20} />
<Skeleton height={20} />
<Skeleton height={20} />
</>
<PaginatedListSkeleton />
)}
</>
);
@ -64,21 +55,10 @@ const query = gql`
query BooksList($input: BookManyInput!) {
bookMany(input: $input) {
items {
id
title
description
author {
id
username
}
updatedAt
progressStatus
ratingTotal
reviewsTotal
wordsTotal
chaptersTotal
...Book
}
total
}
}
${bookFragment}
`;

View file

@ -1,6 +1,6 @@
import { Button, Card, Stack, Text, Wrap } from '@chakra-ui/react';
import isHotkey from 'is-hotkey';
import React, { KeyboardEvent, MouseEvent, useCallback, useMemo, useState } from 'react';
import React, { KeyboardEvent, MouseEvent, useCallback, useMemo } from 'react';
import { createEditor, Descendant, Editor, Element as SlateElement, Transforms } from 'slate';
import { withHistory } from 'slate-history';
import {
@ -44,7 +44,7 @@ export function EditorSlate() {
}
}}
>
<RichTextExample />
<EditorViewport />
</Card.Body>
</Card.Root>
</>
@ -65,7 +65,7 @@ type AlignType = (typeof TEXT_ALIGN_TYPES)[number];
type ListType = (typeof LIST_TYPES)[number];
type CustomElementFormat = CustomElementType | AlignType | ListType;
const RichTextExample = () => {
function EditorViewport() {
const renderElement = useCallback((props: RenderElementProps) => <Element {...props} />, []);
const renderLeaf = useCallback((props: RenderLeafProps) => <Leaf {...props} />, []);
const editor = useMemo(() => withHistory(withReact(createEditor())), []);
@ -123,12 +123,17 @@ const RichTextExample = () => {
<BlockButton format="right" icon="nf nf-fa-align_right" />
<BlockButton format="justify" icon="nf nf-fa-align_justify" />
</Wrap>
<Stack width="100%" display="flex" justifyContent="center" alignItems="center">
<Stack
width="100%"
maxWidth="100%"
paddingX={3}
display="flex"
justifyContent="center"
alignItems="center"
>
<Prose
width="100%"
height="100%"
maxWidth="unset"
maxHeight="unset"
minWidth="100%"
display="flex"
justifyContent="center"
alignItems="center"
@ -137,7 +142,11 @@ const RichTextExample = () => {
className="slate-editor"
style={{
outline: 'none',
minWidth: '100%'
minWidth: '100%',
maxWidth: 'unset',
maxHeight: '75vh',
padding: 0,
overflowY: 'auto'
}}
renderElement={renderElement}
renderLeaf={renderLeaf}
@ -158,9 +167,9 @@ const RichTextExample = () => {
</Stack>
</Slate>
);
};
}
const toggleBlock = (editor: CustomEditor, format: CustomElementFormat) => {
function toggleBlock(editor: CustomEditor, format: CustomElementFormat) {
const isActive = isBlockActive(editor, format, isAlignType(format) ? 'align' : 'type');
const isList = isListType(format);
@ -188,9 +197,9 @@ const toggleBlock = (editor: CustomEditor, format: CustomElementFormat) => {
const block = { type: format, children: [] };
Transforms.wrapNodes(editor, block);
}
};
}
const toggleMark = (editor: CustomEditor, format: CustomTextKey) => {
function toggleMark(editor: CustomEditor, format: CustomTextKey) {
const isActive = isMarkActive(editor, format);
if (isActive) {
@ -198,13 +207,13 @@ const toggleMark = (editor: CustomEditor, format: CustomTextKey) => {
} else {
Editor.addMark(editor, format, true);
}
};
}
const isBlockActive = (
function isBlockActive(
editor: CustomEditor,
format: CustomElementFormat,
blockType: 'type' | 'align' = 'type'
) => {
) {
const { selection } = editor;
if (!selection) return false;
@ -223,15 +232,15 @@ const isBlockActive = (
})
);
return !!match;
};
return Boolean(match);
}
const isMarkActive = (editor: CustomEditor, format: CustomTextKey) => {
function isMarkActive(editor: CustomEditor, format: CustomTextKey) {
const marks = Editor.marks(editor);
return marks ? marks[format] === true : false;
};
}
const Element = ({ attributes, children, element }: RenderElementProps) => {
function Element({ attributes, children, element }: RenderElementProps) {
const style: React.CSSProperties = {};
if (isAlignElement(element)) {
style.textAlign = element.align as AlignType;
@ -280,9 +289,9 @@ const Element = ({ attributes, children, element }: RenderElementProps) => {
</p>
);
}
};
}
const Leaf = ({ attributes, children, leaf }: RenderLeafProps) => {
function Leaf({ attributes, children, leaf }: RenderLeafProps) {
if (leaf.bold) {
children = <strong>{children}</strong>;
}
@ -300,7 +309,7 @@ const Leaf = ({ attributes, children, leaf }: RenderLeafProps) => {
}
return <span {...attributes}>{children}</span>;
};
}
interface BlockButtonProps {
format: CustomElementFormat;
@ -308,7 +317,7 @@ interface BlockButtonProps {
text?: string;
}
const BlockButton = ({ format, icon, text }: BlockButtonProps) => {
function BlockButton({ format, icon, text }: BlockButtonProps) {
const editor = useSlate();
return (
<Button
@ -325,7 +334,7 @@ const BlockButton = ({ format, icon, text }: BlockButtonProps) => {
</Stack>
</Button>
);
};
}
interface MarkButtonProps {
format: CustomTextKey;
@ -333,7 +342,7 @@ interface MarkButtonProps {
text?: string;
}
const MarkButton = ({ format, icon, text }: MarkButtonProps) => {
function MarkButton({ format, icon, text }: MarkButtonProps) {
const editor = useSlate();
return (
<Button
@ -347,16 +356,16 @@ const MarkButton = ({ format, icon, text }: MarkButtonProps) => {
{text}
</Button>
);
};
}
const isAlignType = (format: CustomElementFormat): format is AlignType => {
function isAlignType(format: CustomElementFormat): format is AlignType {
return TEXT_ALIGN_TYPES.includes(format as AlignType);
};
}
const isListType = (format: CustomElementFormat): format is ListType => {
function isListType(format: CustomElementFormat): format is ListType {
return LIST_TYPES.includes(format as ListType);
};
}
const isAlignElement = (element: CustomElement): element is CustomElementWithAlign => {
function isAlignElement(element: CustomElement): element is CustomElementWithAlign {
return 'align' in element;
};
}

View file

@ -1,56 +0,0 @@
import { Card, Heading } from '@chakra-ui/react';
import { useState } from 'react';
import { BaseEditor, createEditor, Descendant } from 'slate';
import { Editable, ReactEditor, Slate, withReact } from 'slate-react';
type CustomElement = { type: 'paragraph'; children: CustomText[] };
type CustomText = { text: string };
declare module 'slate' {
interface CustomTypes {
Editor: BaseEditor & ReactEditor;
Element: CustomElement;
Text: CustomText;
}
}
export function EditorSlate() {
const initialValue: Descendant[] = [
{
type: 'paragraph',
children: [{ text: 'A line of text in a paragraph.' }]
}
];
const [editor] = useState(() => withReact(createEditor()));
return (
// Add the editable component inside the context.
<>
<Card.Root
variant={'subtle'}
width="100%"
maxWidth="840px"
minHeight="sm"
justifyContent="center"
alignItems="center"
>
<Card.Header>
<Heading textAlign="center">Editor</Heading>
</Card.Header>
<Card.Body
width="90%"
css={{
'& *': {
fontSize: 'revert',
fontWeight: 'revert'
}
}}
>
<Slate editor={editor} initialValue={initialValue}>
<Editable />
</Slate>
</Card.Body>
</Card.Root>
</>
);
}

View file

@ -0,0 +1,25 @@
import { Text } from '@chakra-ui/react';
import { LinkProps as LinkExtended, Link as TanstackLink } from '@tanstack/react-router';
import { ReactNode } from 'react';
interface LinkProps {
children: ReactNode;
to: LinkExtended['to'];
link?: LinkExtended;
}
export function Link(props: LinkProps) {
return (
<TanstackLink {...props}>
<Text
color="colorPalette.500"
_hover={{
textDecoration: 'underline',
textDecorationColor: 'colorPalette.700'
}}
>
{props.children}
</Text>
</TanstackLink>
);
}

View file

@ -1,7 +1,13 @@
import { Button, Icon, Input, InputGroup, Stack } from '@chakra-ui/react';
import { useState } from 'react';
export function WorkingListSearch({ onSubmit }: { onSubmit: (value: string) => void }) {
export function ListSearch({
placeholder,
onSubmit
}: {
readonly placeholder?: string;
readonly onSubmit: (value: string) => void;
}) {
const [searchValue, setSearchValue] = useState<string>('');
return (
<form
@ -22,7 +28,8 @@ export function WorkingListSearch({ onSubmit }: { onSubmit: (value: string) => v
<Input
value={searchValue}
onChange={event => setSearchValue(event.currentTarget.value)}
placeholder="Search works..."
onSubmit={event => event.preventDefault()}
placeholder={placeholder}
/>
</InputGroup>
<Button type="submit" border="none">

View file

@ -0,0 +1,17 @@
import { Skeleton, Stack } from '@chakra-ui/react';
export function PaginatedListSkeleton({ disableSearch }: { readonly disableSearch?: boolean }) {
return (
<>
{!disableSearch && (
<Stack direction="row" justifyContent="center" alignItems="center" gap={2}>
<Skeleton height={10} width="80%" />
<Skeleton height={10} width="calc(20% - 6px)" />
</Stack>
)}
<Skeleton height={20} />
<Skeleton height={20} />
<Skeleton height={20} />
</>
);
}

View file

@ -11,6 +11,7 @@ export function Pagination({
page: number;
setPage: (value: number) => void;
pageSize?: number;
setPageSize?: (value: number) => void;
}) {
return (
<ChakraPagination.Root

View file

@ -1,7 +1,8 @@
import { Card, List, Text } from '@chakra-ui/react';
import { WorkingCardProps, WorkingListCard } from './WorkingListCard';
import { WorkingListCard } from './WorkingListCard';
import { BookOne } from '../interfaces';
export function WorkingList({ workingsList }: { workingsList: WorkingCardProps[] }) {
export function WorkingList({ workingsList }: { workingsList: BookOne[] }) {
return (
<List.Root variant="plain" gap={4}>
{workingsList.length !== 0 ? (

View file

@ -1,23 +1,14 @@
import { Badge, Box, Card, Heading, Link, Stack, Text } from '@chakra-ui/react';
import { WorkingProgressStatus } from '@generated/types';
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';
export interface WorkingCardProps {
id: string;
title: string;
description: string;
author: { id: string; username: string };
progressStatus: WorkingProgressStatus;
updatedAt: string;
chaptersTotal: number;
wordsTotal: number;
ratingTotal: number;
reviewsTotal: number;
}
import { WorkingReviewsBadge } from './WorkingReviewsTotalBadge';
export function WorkingListCard({
id,
title,
description,
author,
@ -27,29 +18,47 @@ export function WorkingListCard({
wordsTotal,
ratingTotal,
reviewsTotal
}: WorkingCardProps) {
}: BookOne) {
return (
<Card.Root width="100%" variant="elevated">
<Card.Body>
<Stack>
<Stack direction="row" justifyContent="space-between">
<Link>
<Heading size="lg">{title}</Heading>
</Link>
<Text as="div" minWidth={0}>
<Link to={`/work/book/${id}`}>
<Heading
color="colorPalette.400"
_hover={{
textDecoration: 'underline',
textDecorationColor: 'colorPalette.700'
}}
>
{title}
</Heading>
</Link>
</Text>
<Stack direction="row" justifyContent="center" alignItems="center">
<WorkingRatingBadge ratingTotal={ratingTotal} />
<IconBadge backgroundColor="orange.400" iconClass="nf nf-oct-comment">
<Text>{reviewsTotal}</Text>
</IconBadge>
<WorkingReviewsBadge reviewsTotal={reviewsTotal} />
<WorkingProgressStatusBadge progressStatus={progressStatus} />
</Stack>
</Stack>
<Stack>
<Stack direction="row">
<Text>by:</Text>
<Link>{author.username}</Link>
<Link>by:</Link>
<Link to={`/profile/${author.username}`}>
<Text
color="colorPalette.400"
_hover={{
textDecoration: 'underline',
textDecorationColor: 'colorPalette.700'
}}
>
{author.username}
</Text>
</Link>
</Stack>
<Stack direction="row">
@ -57,7 +66,7 @@ export function WorkingListCard({
<Badge size="md" variant="solid">
<Stack direction="row">
<Text>last updated:</Text>
<Text>{updatedAt}</Text>
<Text>{toFormattedDate({ value: updatedAt })}</Text>
</Stack>
</Badge>
</Box>

View file

@ -12,7 +12,7 @@ export function WorkingProgressStatusBadge({
progressStatus: WorkingProgressStatus;
}) {
return (
<Box flex={1} justifyContent="center" alignItems="center">
<Box>
<Badge
variant="solid"
backgroundColor={toBgColorByProgressStatus(progressStatus)}

View file

@ -1,4 +1,4 @@
import { BadgeProps, Text } from '@chakra-ui/react';
import { BadgeProps, Box, Text } from '@chakra-ui/react';
import { IconBadge } from './IconBadge';
interface WorkingRatingParams {
@ -21,8 +21,10 @@ export function WorkingRatingBadge({ ratingTotal }: { ratingTotal: number }) {
iconClass: 'nf nf-fa-angle_down'
};
return (
<IconBadge backgroundColor={ratingParams.color} iconClass={ratingParams.iconClass}>
<Text>{ratingParams.rating}</Text>
</IconBadge>
<Box>
<IconBadge backgroundColor={ratingParams.color} iconClass={ratingParams.iconClass}>
<Text>{ratingParams.rating}</Text>
</IconBadge>
</Box>
);
}

View file

@ -0,0 +1,12 @@
import { Box, Text } from '@chakra-ui/react';
import { IconBadge } from './IconBadge';
export function WorkingReviewsBadge({ reviewsTotal }: { readonly reviewsTotal: number }) {
return (
<Box>
<IconBadge backgroundColor="orange.400" iconClass="nf nf-oct-comment">
<Text>{reviewsTotal}</Text>
</IconBadge>
</Box>
);
}

View file

@ -1,26 +1,22 @@
import { Link as ChakraLink } from "@chakra-ui/react";
import { createLink, LinkComponent } from "@tanstack/react-router";
import * as React from "react";
import { Link as ChakraLink } from '@chakra-ui/react';
import { createLink, LinkComponent } from '@tanstack/react-router';
import * as React from 'react';
interface ChakraLinkProps
extends Omit<React.ComponentPropsWithoutRef<typeof ChakraLink>, "href"> {}
interface ChakraLinkProps extends Omit<React.ComponentPropsWithoutRef<typeof ChakraLink>, 'href'> {}
const ChakraLinkComponent = React.forwardRef<
HTMLAnchorElement,
ChakraLinkProps
>((props, ref) => {
const ChakraLinkComponent = React.forwardRef<HTMLAnchorElement, ChakraLinkProps>((props, ref) => {
return <ChakraLink ref={ref} {...props} />;
});
const CreatedLinkComponent = createLink(ChakraLinkComponent);
export const Link: LinkComponent<typeof ChakraLinkComponent> = (props) => {
export const Link: LinkComponent<typeof ChakraLinkComponent> = props => {
return (
<CreatedLinkComponent
textDecoration={"underline"}
_hover={{ textDecoration: "none" }}
_focus={{ textDecoration: "none" }}
preload={"intent"}
textDecoration={'underline'}
_hover={{ textDecoration: 'none' }}
_focus={{ textDecoration: 'none' }}
preload={'intent'}
{...props}
/>
);

View file

@ -7,10 +7,34 @@ export const sessionFragment = gql`
}
`;
// refreshToken {
// token
// maxAge
// secure
// httpOnly
// sameSite
// }
export const bookFragment = gql`
fragment Book on Book {
id
title
description
author {
id
username
}
createdAt
updatedAt
progressStatus
wordsTotal
chaptersTotal
ratingTotal
reviewsTotal
}
`;
export const bookChapterFragment = gql`
fragment BookChapter on BookChapter {
id
title
createdAt
updatedAt
foreword
footnote
sequenceNumber
reviewsTotal
}
`;

View file

@ -1,9 +1,16 @@
import { WorkingProgressStatus } from '@generated/types';
import dayjs from 'dayjs';
export function assertUnreachable(value: never): never {
throw new Error(`Unhandled scenario: ${value}`);
}
export function toFormattedDate({ value }: { value: string }): string {
const date = new Date(value);
const day = dayjs(date).format('DD.MM.YYYY');
return day;
}
export function toBgColorByProgressStatus(value: WorkingProgressStatus): string {
if (value === WorkingProgressStatus.InProgress) return 'yellow.500';
if (value === WorkingProgressStatus.Frozen) return 'blue.500';

View file

@ -10,6 +10,7 @@ import { DocumentNode } from 'graphql';
interface UsePaginatedQueryProps {
query: DocumentNode;
input: {
[key: string]: any; // for the custom inputs
filter: {
terms?: string | null;
offset?: number | null;

18
app/client/interfaces.tsx Normal file
View file

@ -0,0 +1,18 @@
import { WorkingProgressStatus } from '@generated/types';
export interface BookOne {
readonly id: string;
readonly title: string;
readonly description: string;
readonly author: {
readonly id: string;
readonly username: string;
};
readonly createdAt: string;
readonly updatedAt: string;
readonly progressStatus: WorkingProgressStatus;
readonly wordsTotal: number;
readonly chaptersTotal: number;
readonly ratingTotal: number;
readonly reviewsTotal: number;
}

View file

@ -1,15 +1,5 @@
import {
Link as ChakraLink,
Icon,
Input,
InputGroup,
Kbd,
Menu,
Separator,
Stack,
Text
} from '@chakra-ui/react';
import { createRootRouteWithContext, Link, Outlet } from '@tanstack/react-router';
import { Icon, Input, InputGroup, Kbd, Menu, Separator, Stack, Text } from '@chakra-ui/react';
import { createRootRouteWithContext, Outlet, Link } from '@tanstack/react-router';
import React from 'react';
import { Avatar } from '../components/ui/avatar';
import { Button } from '../components/ui/button';
@ -18,7 +8,6 @@ import { Tooltip } from '../components/ui/tooltip';
import { useAuth } from '../hooks/auth';
import { useIdentity } from '../hooks/identity';
import { appName } from '../main';
// import { Link as ChakraLink } from '../components/ui/link';
const TanStackRouterDevtools =
process.env.NODE_ENV === 'production'
@ -67,12 +56,28 @@ export const Route = createRootRouteWithContext<RouterContext>()({
<>
<Stack direction="row" alignItems="center">
<Link to="/editor">
<ChakraLink>editor</ChakraLink>
<Text
color="colorPalette.400"
_hover={{
textDecoration: 'underline',
textDecorationColor: 'colorPalette.700'
}}
>
editor
</Text>
</Link>
</Stack>
<Stack direction="row" alignItems="center">
<Link to="/new/book">
<ChakraLink>create</ChakraLink>
<Text
color="colorPalette.400"
_hover={{
textDecoration: 'underline',
textDecorationColor: 'colorPalette.700'
}}
>
create
</Text>
</Link>
</Stack>
</>
@ -99,10 +104,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
{identity != null ? (
<Menu.Root>
<Menu.Trigger css={{ '&:hover': { cursor: 'pointer' } }}>
<Avatar
size="sm"
// src="https://avatars.githubusercontent.com/u/40203084?v=4"
/>
<Avatar size="sm" />
</Menu.Trigger>
<Menu.Positioner mt={2}>
<Menu.Content backgroundColor={'bg.subtle'}>

View file

@ -1,20 +0,0 @@
// import { gql } from '@apollo/client';
import { createLazyFileRoute } from '@tanstack/react-router';
export const Route = createLazyFileRoute('/book')({
component: RouteComponent
});
function RouteComponent() {
return <div>"/book"</div>;
}
// const getBookQuery = gql`
// query BookOne {
// booksMany {
// id
// author
// title
// }
// }
// `;

View file

@ -15,49 +15,3 @@ function RouteComponent() {
</Stack>
);
}
// const workingData1: WorkingCardProps = {
// id: '1',
// title: 'Worth the Candle',
// description: `A teenager struggling after the death of his best friend finds himself in a fantasy
// world - one which seems to be an amalgamation of every Dungeons and Dragons campaign
// they ever played together. Now he's stuck trying to find the answers to why he's there
// and what this world is trying to say. The most terrifying answer might be that this
// world is an expression of the person he was back on Earth.`,
// author: {
// id: '1',
// createdAt: '12.8.2025',
// updatedAt: '4.9.2025',
// username: 'AlexanderWhales'
// },
// progressStatus: WorkingProgressStatus.Finished,
// updatedAt: '20.10.2025',
// chaptersTotal: 10,
// wordsTotal: 34000,
// ratingTotal: 10,
// reviewsTotal: 3
// };
//
// const workingData2: WorkingCardProps = {
// id: '2',
// title: 'Test',
// description: `Description.`,
// author: {
// id: '1',
// createdAt: '12.8.2025',
// updatedAt: '4.9.2025',
// username: 'JohnSmith'
// },
// progressStatus: WorkingProgressStatus.InProgress,
// updatedAt: '20.10.2025',
// chaptersTotal: 10,
// wordsTotal: 1200,
// ratingTotal: -10,
// reviewsTotal: 0
// };
//
// const mockWorkingData = {
// total: 4,
// items: [workingData1, workingData2, { ...workingData1, id: '3' }, { ...workingData2, id: '4' }]
// };
//

View file

@ -1,8 +1,11 @@
import { gql } from '@apollo/client';
import { useToastMutation } from '@app/client/hooks/useToastMutation';
import { Heading, Input, Stack, Textarea } from '@chakra-ui/react';
import { Button } from '@client/components/ui/button';
import { Field } from '@client/components/ui/field';
import { CreateNewBookMutation } from '@generated/types';
import { valibotResolver } from '@hookform/resolvers/valibot';
import { createFileRoute, redirect } from '@tanstack/react-router';
import { createFileRoute, redirect, useNavigate } from '@tanstack/react-router';
import { useForm } from 'react-hook-form';
import * as v from 'valibot';
@ -28,10 +31,16 @@ interface FormValues {
}
function NewBookForm() {
const navigate = useNavigate();
const { update: bookCreate } = useToastMutation<CreateNewBookMutation>({
mutation: bookCreateMutation,
successMsg: 'Book created successfully!'
});
const schema = v.pipe(
v.object({
title: v.pipe(v.string(), v.nonEmpty('Title is required')),
description: v.pipe(v.string(), v.nonEmpty('Password is required'))
title: v.pipe(v.string(), v.nonEmpty('Title is required'), v.maxLength(60)),
description: v.pipe(v.string(), v.nonEmpty('Description is required'), v.maxLength(900))
})
);
@ -42,7 +51,23 @@ function NewBookForm() {
} = useForm<FormValues>({ resolver: valibotResolver(schema) });
return (
<form style={{ width: '80%' }} onSubmit={handleSubmit(async values => {})}>
<form
style={{ width: '80%', maxWidth: '840px' }}
onSubmit={handleSubmit(async values => {
const { data } = await bookCreate({
variables: {
input: {
title: values.title,
description: values.description
}
}
});
if (data != null)
navigate({
to: `/work/book/${data.bookCreate.id}`
});
})}
>
<Stack>
<Field label="Title" invalid={errors.title != null} errorText={errors.title?.message}>
<Input {...register('title')} />
@ -52,7 +77,7 @@ function NewBookForm() {
invalid={errors.description != null}
errorText={errors.description?.message}
>
<Textarea width="100%" lineHeight={4} {...register('description')} />
<Textarea width="100%" height={'sm'} {...register('description')} />
</Field>
<Button type="submit">Create new book</Button>
</Stack>
@ -60,38 +85,12 @@ function NewBookForm() {
);
}
// function LoginForm() {
// const schema = v.pipe(
// v.object({
// email: v.pipe(v.string(), v.nonEmpty('Email is required'), v.email('Invalid email')),
// password: v.pipe(v.string(), v.nonEmpty('Password is required'))
// })
// );
//
// const {
// register,
// handleSubmit,
// formState: { errors }
// } = useForm<FormValues>({ resolver: valibotResolver(schema) });
//
// const { login } = useAuth();
//
// return (
// <form
// >
// <Stack>
// <Field label="Email" invalid={errors.email != null} errorText={errors.email?.message}>
// <Input {...register('email')} />
// </Field>
// <Field
// label="Password"
// invalid={errors.password != null}
// errorText={errors.password?.message}
// >
// <PasswordInput {...register('password')} />
// </Field>
// <Button type="submit">Sign in</Button>
// </Stack>
// </form>
// );
// }
const bookCreateMutation = gql`
mutation CreateNewBook($input: BookCreateInput!) {
bookCreate(input: $input) {
id
title
description
}
}
`;

View file

@ -0,0 +1,185 @@
import { gql, useQuery } from '@apollo/client';
import { PaginatedListSkeleton } from '@app/client/components/PaginatedListSkeleton';
import { Pagination } from '@app/client/components/Pagination';
import { WorkingProgressStatusBadge } from '@app/client/components/WorkingProgressStatusBadge';
import { WorkingRatingBadge } from '@app/client/components/WorkingRatingBadge';
import { WorkingReviewsBadge } from '@app/client/components/WorkingReviewsTotalBadge';
import { bookChapterFragment, bookFragment } from '@app/client/fragments';
import { toFormattedDate } from '@app/client/helpers';
import { usePaginatedQuery } from '@app/client/hooks/usePaginatedQuery';
import { BookOne } from '@app/client/interfaces';
import {
Heading,
HStack,
Skeleton,
SkeletonCircle,
SkeletonText,
Stack,
Text
} from '@chakra-ui/react';
import { BookChapterListQuery, BookOverviewQuery } from '@generated/types';
import { createFileRoute, Link } from '@tanstack/react-router';
import { useState } from 'react';
export const Route = createFileRoute('/work/book/$bookId')({
component: RouteComponent
});
function RouteComponent() {
const { bookId: id } = Route.useParams();
const { data: bookData } = useQuery<BookOverviewQuery>(bookQuery, { variables: { id } });
return (
<Stack width="100%" justifyContent="center" alignItems="center" padding={3}>
{bookData != null ? <BookOverview {...bookData.bookOne} /> : <BookOverviewSkeleton />}
</Stack>
);
}
function BookOverview({
id,
title,
description,
author,
progressStatus,
createdAt,
updatedAt,
wordsTotal,
chaptersTotal,
reviewsTotal,
ratingTotal
}: BookOne) {
return (
<Stack width="80%" maxWidth="840px" gap={3}>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Heading color="colorPalette.400" size="2xl" overflow="auto">
{title}
</Heading>
</Stack>
<Stack direction="column" gap={1}>
<Stack direction="row">
<Link>
<Text fontWeight="bold">by:</Text>
</Link>
<Link to={`/profile/${author.username}`}>
<Text
color="colorPalette.400"
_hover={{
textDecoration: 'underline',
textDecorationColor: 'colorPalette.700'
}}
>
{author.username}
</Text>
</Link>
</Stack>
<Stack direction="row">
<Text fontWeight="bold">created at:</Text>
<Text>{toFormattedDate({ value: createdAt })}</Text>
</Stack>
<Stack direction="row">
<Text fontWeight="bold">last updated:</Text>
<Text>{toFormattedDate({ value: updatedAt })}</Text>
</Stack>
<Stack direction="row">
<Text fontWeight="bold">chapters:</Text>
<Text>{chaptersTotal}</Text>
</Stack>
<Stack direction="row">
<Text fontWeight="bold">words:</Text>
<Text>{wordsTotal}</Text>
</Stack>
</Stack>
<Stack direction="row" justifyContent="left" alignItems="center">
<WorkingRatingBadge ratingTotal={ratingTotal} />
<WorkingReviewsBadge reviewsTotal={reviewsTotal} />
<WorkingProgressStatusBadge progressStatus={progressStatus} />
</Stack>
<Text>{description}</Text>
<BookChapterList id={id} />
</Stack>
);
}
function BookChapterList({ id }: { readonly id: string }) {
const [chaptersListPage, setChaptersListPage] = useState<number>(1);
const [chaptersListPageSize, setChaptersListPageSize] = useState<number>(10);
const startIndex = (chaptersListPage - 1) * chaptersListPageSize;
const { data } = usePaginatedQuery<BookChapterListQuery>({
query: bookChapterListQuery,
input: {
bookId: id,
filter: {
offset: startIndex,
size: chaptersListPageSize
}
}
});
return (
<Stack>
{data != null ? (
<>
<Stack>...chapters</Stack>
{data.bookChapterMany.total > chaptersListPageSize && (
<Stack justifyContent="center" alignItems="center">
<Pagination
total={data.bookChapterMany.total}
page={chaptersListPage}
setPage={setChaptersListPage}
pageSize={chaptersListPageSize}
setPageSize={setChaptersListPageSize}
/>
</Stack>
)}
</>
) : (
<PaginatedListSkeleton disableSearch />
)}
</Stack>
);
}
function BookOverviewSkeleton() {
return (
<Stack width="80%" maxWidth="840px">
<Stack gap="6" maxWidth="100%">
<HStack width="full">
<SkeletonCircle size="10" />
<SkeletonText noOfLines={5} />
</HStack>
<Skeleton height="200px" />
</Stack>
</Stack>
);
}
const bookQuery = gql`
query BookOverview($id: ID!) {
bookOne(id: $id) {
...Book
}
}
${bookFragment}
`;
const bookChapterListQuery = gql`
query BookChapterList($input: BookChapterManyInput!) {
bookChapterMany(input: $input) {
items {
...BookChapter
}
total
}
}
${bookChapterFragment}
`;

View file

@ -8,8 +8,6 @@
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { createFileRoute } from '@tanstack/react-router'
// Import Routes
import { Route as rootRoute } from './pages/__root'
@ -20,19 +18,10 @@ import { Route as AboutImport } from './pages/about'
import { Route as IndexImport } from './pages/index'
import { Route as ProfileUsernameImport } from './pages/profile/$username'
import { Route as NewBookImport } from './pages/new/book'
// Create Virtual Routes
const BookLazyImport = createFileRoute('/book')()
import { Route as WorkBookBookIdImport } from './pages/work/book/$bookId'
// Create/Update Routes
const BookLazyRoute = BookLazyImport.update({
id: '/book',
path: '/book',
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./pages/book.lazy').then((d) => d.Route))
const RegisterRoute = RegisterImport.update({
id: '/register',
path: '/register',
@ -75,6 +64,12 @@ const NewBookRoute = NewBookImport.update({
getParentRoute: () => rootRoute,
} as any)
const WorkBookBookIdRoute = WorkBookBookIdImport.update({
id: '/work/book/$bookId',
path: '/work/book/$bookId',
getParentRoute: () => rootRoute,
} as any)
// Populate the FileRoutesByPath interface
declare module '@tanstack/react-router' {
@ -114,13 +109,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof RegisterImport
parentRoute: typeof rootRoute
}
'/book': {
id: '/book'
path: '/book'
fullPath: '/book'
preLoaderRoute: typeof BookLazyImport
parentRoute: typeof rootRoute
}
'/new/book': {
id: '/new/book'
path: '/new/book'
@ -135,6 +123,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ProfileUsernameImport
parentRoute: typeof rootRoute
}
'/work/book/$bookId': {
id: '/work/book/$bookId'
path: '/work/book/$bookId'
fullPath: '/work/book/$bookId'
preLoaderRoute: typeof WorkBookBookIdImport
parentRoute: typeof rootRoute
}
}
}
@ -146,9 +141,9 @@ export interface FileRoutesByFullPath {
'/editor': typeof EditorRoute
'/login': typeof LoginRoute
'/register': typeof RegisterRoute
'/book': typeof BookLazyRoute
'/new/book': typeof NewBookRoute
'/profile/$username': typeof ProfileUsernameRoute
'/work/book/$bookId': typeof WorkBookBookIdRoute
}
export interface FileRoutesByTo {
@ -157,9 +152,9 @@ export interface FileRoutesByTo {
'/editor': typeof EditorRoute
'/login': typeof LoginRoute
'/register': typeof RegisterRoute
'/book': typeof BookLazyRoute
'/new/book': typeof NewBookRoute
'/profile/$username': typeof ProfileUsernameRoute
'/work/book/$bookId': typeof WorkBookBookIdRoute
}
export interface FileRoutesById {
@ -169,9 +164,9 @@ export interface FileRoutesById {
'/editor': typeof EditorRoute
'/login': typeof LoginRoute
'/register': typeof RegisterRoute
'/book': typeof BookLazyRoute
'/new/book': typeof NewBookRoute
'/profile/$username': typeof ProfileUsernameRoute
'/work/book/$bookId': typeof WorkBookBookIdRoute
}
export interface FileRouteTypes {
@ -182,9 +177,9 @@ export interface FileRouteTypes {
| '/editor'
| '/login'
| '/register'
| '/book'
| '/new/book'
| '/profile/$username'
| '/work/book/$bookId'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@ -192,9 +187,9 @@ export interface FileRouteTypes {
| '/editor'
| '/login'
| '/register'
| '/book'
| '/new/book'
| '/profile/$username'
| '/work/book/$bookId'
id:
| '__root__'
| '/'
@ -202,9 +197,9 @@ export interface FileRouteTypes {
| '/editor'
| '/login'
| '/register'
| '/book'
| '/new/book'
| '/profile/$username'
| '/work/book/$bookId'
fileRoutesById: FileRoutesById
}
@ -214,9 +209,9 @@ export interface RootRouteChildren {
EditorRoute: typeof EditorRoute
LoginRoute: typeof LoginRoute
RegisterRoute: typeof RegisterRoute
BookLazyRoute: typeof BookLazyRoute
NewBookRoute: typeof NewBookRoute
ProfileUsernameRoute: typeof ProfileUsernameRoute
WorkBookBookIdRoute: typeof WorkBookBookIdRoute
}
const rootRouteChildren: RootRouteChildren = {
@ -225,9 +220,9 @@ const rootRouteChildren: RootRouteChildren = {
EditorRoute: EditorRoute,
LoginRoute: LoginRoute,
RegisterRoute: RegisterRoute,
BookLazyRoute: BookLazyRoute,
NewBookRoute: NewBookRoute,
ProfileUsernameRoute: ProfileUsernameRoute,
WorkBookBookIdRoute: WorkBookBookIdRoute,
}
export const routeTree = rootRoute
@ -245,9 +240,9 @@ export const routeTree = rootRoute
"/editor",
"/login",
"/register",
"/book",
"/new/book",
"/profile/$username"
"/profile/$username",
"/work/book/$bookId"
]
},
"/": {
@ -265,14 +260,14 @@ export const routeTree = rootRoute
"/register": {
"filePath": "register.tsx"
},
"/book": {
"filePath": "book.lazy.tsx"
},
"/new/book": {
"filePath": "new/book.tsx"
},
"/profile/$username": {
"filePath": "profile/$username.tsx"
},
"/work/book/$bookId": {
"filePath": "work/book/$bookId.tsx"
}
}
}

View file

@ -2,17 +2,18 @@
import { MutationResolvers, QueryResolvers } from '@generated/types';
/* Query */
import { IdentityResolver } from './resolvers/identityResolver';
import { UserOneResolver } from './resolvers/userOneResolver';
import { BookChapterManyResolver } from './resolvers/bookChapterMany';
import { BookChapterOneResolver } from './resolvers/bookChapterOne';
import { BookChapterManyResolver } from './resolvers/bookChapterManyResolver';
import { BookChapterOneResolver } from './resolvers/bookChapterOneResolver';
import { BookManyResolver } from './resolvers/bookManyResolver';
import { BookOneResolver } from './resolvers/bookOneResolver';
import { IdentityResolver } from './resolvers/identityResolver';
import { UserOneResolver } from './resolvers/userOneResolver';
/* Mutation */
import { LoginResolver } from './resolvers/auth/login';
import { LogoutResolver } from './resolvers/auth/logout';
import { SignUpResolver } from './resolvers/auth/signUp';
import { BookCreateResolver } from './resolvers/bookCreateResolver';
export const Query: QueryResolvers = {
bookOne: BookOneResolver,
@ -26,7 +27,8 @@ export const Query: QueryResolvers = {
export const Mutation: MutationResolvers = {
signUp: SignUpResolver,
login: LoginResolver,
logout: LogoutResolver
logout: LogoutResolver,
bookCreate: BookCreateResolver
};
export const Subscription = {};

View file

@ -1,6 +1,7 @@
import { IdentityContext } from '@app/server/identity';
import { prisma } from '@app/server/prisma/client';
import { BookChapterManyOutput, QueryBookChapterManyArgs } from '@generated/types';
import { Prisma } from '@prisma/client';
import { toBookChapterSchema } from './mappers/bookChapterMapper';
export async function BookChapterManyResolver(
@ -9,11 +10,14 @@ export async function BookChapterManyResolver(
{ identity }: IdentityContext
): Promise<BookChapterManyOutput> {
const { bookId, filter } = input;
const terms = filter.terms?.toLowerCase();
const bookChapterWhere: Prisma.BookChapterWhereInput = {
AND: [{ bookId }, { OR: terms != null ? [{ title: { contains: terms } }] : undefined }]
};
const items = await prisma.bookChapter.findMany({
where: {
bookId
},
where: bookChapterWhere,
skip: filter.offset ?? 0,
take: filter.size ?? 10
});

View file

@ -0,0 +1,27 @@
import { ensureIdentity } from '@app/server/helpers';
import { IdentityContext } from '@app/server/identity';
import { prisma, uuid } from '@app/server/prisma/client';
import { Book, MutationBookCreateArgs } from '@generated/types';
import { toBookSchema } from './mappers/bookMapper';
export async function BookCreateResolver(
_: any,
{ input }: MutationBookCreateArgs,
{ identity }: IdentityContext
): Promise<Book> {
ensureIdentity(identity);
console.log(input.title.length);
if (input.title.length > 60) throw new Error('Title is too long.');
if (input.description.length > 900) throw new Error('Description is too long.');
const model = await prisma.book.create({
data: {
id: uuid(),
title: input.title,
description: input.description,
authorId: identity.id
}
});
return toBookSchema(model);
}

View file

@ -1,6 +1,7 @@
import { IdentityContext } from '@app/server/identity';
import { prisma } from '@app/server/prisma/client';
import { BookManyOutput, QueryBookManyArgs } from '@generated/types';
import { Prisma } from '@prisma/client';
import { toBookSchema } from './mappers/bookMapper';
export async function BookManyResolver(
@ -11,19 +12,19 @@ export async function BookManyResolver(
const { filter } = input;
const terms = filter.terms?.toLowerCase();
const bookWhere: Prisma.BookWhereInput = {
OR:
terms != null ? [{ title: { contains: terms } }, { author: { username: terms } }] : undefined
};
const items = await prisma.book.findMany({
where: {
OR:
terms != null
? [{ title: { contains: terms } }, { author: { username: terms } }]
: undefined
},
where: bookWhere,
skip: filter.offset ?? 0,
take: filter.size ?? 10
});
return {
items: await Promise.all(items.map(value => toBookSchema(value))),
total: items.length
total: await prisma.book.count({ where: bookWhere })
};
}

View file

@ -14,6 +14,7 @@ type Mutation {
signUp(input: SignUpInput!): Void
login(input: LoginInput!): Session!
logout: Void
bookCreate(input: BookCreateInput!): Book!
}
input SignUpInput {

7
app/server/helpers.ts Normal file
View file

@ -0,0 +1,7 @@
import { IdentityContext } from './identity';
export function ensureIdentity(
identity: IdentityContext['identity']
): asserts identity is NonNullable<IdentityContext['identity']> {
if (identity == null) throw new Error('Unauthorized.');
}