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}
/>
);