Merge master into storybook-deploy

This commit is contained in:
github-actions[bot]
2024-01-02 08:19:18 +00:00
committed by GitHub
58 changed files with 688 additions and 131 deletions

View File

@@ -27,6 +27,7 @@
"@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-tabs": "^1.0.4",
"@reduxjs/toolkit": "^2.0.1", "@reduxjs/toolkit": "^2.0.1",
"@storybook/theming": "^7.6.6", "@storybook/theming": "^7.6.6",
"async-mutex": "^0.4.0",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
@@ -42,7 +43,7 @@
"@hookform/devtools": "^4.3.1", "@hookform/devtools": "^4.3.1",
"@it-incubator/eslint-config": "^1.0.2", "@it-incubator/eslint-config": "^1.0.2",
"@it-incubator/prettier-config": "^0.1.2", "@it-incubator/prettier-config": "^0.1.2",
"@it-incubator/stylelint-config": "^1.0.1", "@it-incubator/stylelint-config": "^1.0.2",
"@storybook/addon-essentials": "^7.6.6", "@storybook/addon-essentials": "^7.6.6",
"@storybook/addon-interactions": "^7.6.6", "@storybook/addon-interactions": "^7.6.6",
"@storybook/addon-links": "^7.6.6", "@storybook/addon-links": "^7.6.6",

17
pnpm-lock.yaml generated
View File

@@ -38,6 +38,9 @@ dependencies:
'@storybook/theming': '@storybook/theming':
specifier: ^7.6.6 specifier: ^7.6.6
version: 7.6.6(react-dom@18.2.0)(react@18.2.0) version: 7.6.6(react-dom@18.2.0)(react@18.2.0)
async-mutex:
specifier: ^0.4.0
version: 0.4.0
clsx: clsx:
specifier: ^2.0.0 specifier: ^2.0.0
version: 2.0.0 version: 2.0.0
@@ -80,8 +83,8 @@ devDependencies:
specifier: ^0.1.2 specifier: ^0.1.2
version: 0.1.2 version: 0.1.2
'@it-incubator/stylelint-config': '@it-incubator/stylelint-config':
specifier: ^1.0.1 specifier: ^1.0.2
version: 1.0.1(stylelint-config-clean-order@5.2.0)(stylelint-config-standard-scss@12.0.0)(stylelint@16.1.0) version: 1.0.2(stylelint-config-clean-order@5.2.0)(stylelint-config-standard-scss@12.0.0)(stylelint@16.1.0)
'@storybook/addon-essentials': '@storybook/addon-essentials':
specifier: ^7.6.6 specifier: ^7.6.6
version: 7.6.6(@types/react-dom@18.2.18)(@types/react@18.2.46)(react-dom@18.2.0)(react@18.2.0) version: 7.6.6(@types/react-dom@18.2.18)(@types/react@18.2.46)(react-dom@18.2.0)(react@18.2.0)
@@ -2175,8 +2178,8 @@ packages:
prettier: 3.0.0 prettier: 3.0.0
dev: true dev: true
/@it-incubator/stylelint-config@1.0.1(stylelint-config-clean-order@5.2.0)(stylelint-config-standard-scss@12.0.0)(stylelint@16.1.0): /@it-incubator/stylelint-config@1.0.2(stylelint-config-clean-order@5.2.0)(stylelint-config-standard-scss@12.0.0)(stylelint@16.1.0):
resolution: {integrity: sha512-dZuCX0wXtuNwU0BoNM1fbfo9V9DIG4IhtZ67cOgTsv7/zXFOxXCPiOSx2SguCA7sg4P62/oq2pMQq/x/Y4Edag==} resolution: {integrity: sha512-2LIducOMyfhFOGV1GvmpWjjvgRGhEtHw/qBv3Vjw+Nel8jCl9cpH2yJrdehK1QJSOkzvQGs2pPQViouW9FQUCA==}
peerDependencies: peerDependencies:
stylelint: 16.1.0 stylelint: 16.1.0
stylelint-config-clean-order: 5.2.0 stylelint-config-clean-order: 5.2.0
@@ -4854,6 +4857,12 @@ packages:
resolution: {integrity: sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==} resolution: {integrity: sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==}
dev: true dev: true
/async-mutex@0.4.0:
resolution: {integrity: sha512-eJFZ1YhRR8UN8eBLoNzcDPcy/jqjsg6I1AP+KvWQX80BqOSW1oJPJXDylPUEeMr2ZQvHgnQ//Lp6f3RQ1zI7HA==}
dependencies:
tslib: 2.6.2
dev: false
/async@3.2.5: /async@3.2.5:
resolution: {integrity: sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==} resolution: {integrity: sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==}
dev: true dev: true

View File

@@ -1,11 +1,15 @@
import { Provider } from 'react-redux' import { Provider } from 'react-redux'
import { ToastContainer } from 'react-toastify'
import { Router } from '@/router' import { Router } from '@/router'
import { store } from '@/services/store' import { store } from '@/services/store'
import 'react-toastify/dist/ReactToastify.css'
export function App() { export function App() {
return ( return (
<Provider store={store}> <Provider store={store}>
<ToastContainer />
<Router /> <Router />
</Provider> </Provider>
) )

View File

@@ -1,10 +1,10 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import s from './check-email.module.scss'
import { Email } from '../../../assets/icons' import { Email } from '../../../assets/icons'
import { Button, Card, Typography } from '../../ui' import { Button, Card, Typography } from '../../ui'
import s from './check-email.module.scss'
type Props = { type Props = {
email: string email: string
} }

View File

@@ -1,14 +1,15 @@
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { Button, Card, ControlledTextField, Typography } from '../../ui'
import { DevTool } from '@hookform/devtools' import { DevTool } from '@hookform/devtools'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod' import { z } from 'zod'
import s from './new-password.module.scss' import s from './new-password.module.scss'
import { Button, Card, ControlledTextField, Typography } from '../../ui'
const schema = z.object({ const schema = z.object({
password: z.string().nonempty('Enter password'), password: z.string().min(1, 'Enter password'),
}) })
type FormType = z.infer<typeof schema> type FormType = z.infer<typeof schema>

View File

@@ -1,13 +1,14 @@
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { Button, Card, ControlledCheckbox, ControlledTextField, Typography } from '../../ui'
import { DevTool } from '@hookform/devtools' import { DevTool } from '@hookform/devtools'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod' import { z } from 'zod'
import s from './sign-in.module.scss' import s from './sign-in.module.scss'
import { Button, Card, ControlledCheckbox, ControlledTextField, Typography } from '../../ui'
const schema = z.object({ const schema = z.object({
email: z.string().email('Invalid email address').nonempty('Enter email'), email: z.string().email('Invalid email address').nonempty('Enter email'),
password: z.string().nonempty('Enter password'), password: z.string().nonempty('Enter password'),

View File

@@ -1,7 +1,6 @@
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { Button, Card, ControlledTextField, Typography } from '../../ui'
import { DevTool } from '@hookform/devtools' import { DevTool } from '@hookform/devtools'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { omit } from 'remeda' import { omit } from 'remeda'
@@ -9,6 +8,8 @@ import { z } from 'zod'
import s from './sign-up.module.scss' import s from './sign-up.module.scss'
import { Button, Card, ControlledTextField, Typography } from '../../ui'
const schema = z const schema = z
.object({ .object({
email: z.string().email('Invalid email address').nonempty('Enter email'), email: z.string().email('Invalid email address').nonempty('Enter email'),

View File

@@ -1,9 +1,10 @@
import { useState } from 'react' import { useState } from 'react'
import { DeckDialog } from './'
import { Button } from '@/components' import { Button } from '@/components'
import { Meta, StoryObj } from '@storybook/react' import { Meta, StoryObj } from '@storybook/react'
import { DeckDialog } from './'
const meta = { const meta = {
component: DeckDialog, component: DeckDialog,
tags: ['autodocs'], tags: ['autodocs'],

View File

@@ -8,7 +8,7 @@ import s from './deck-dialog.module.scss'
const newDeckSchema = z.object({ const newDeckSchema = z.object({
isPrivate: z.boolean(), isPrivate: z.boolean(),
name: z.string().min(3).max(50), name: z.string().min(3).max(5000),
}) })
type FormValues = z.infer<typeof newDeckSchema> type FormValues = z.infer<typeof newDeckSchema>

View File

@@ -1 +1 @@
export * from './deck-dialog.tsx' export * from './deck-dialog'

View File

@@ -2,7 +2,9 @@ import { Link } from 'react-router-dom'
import { Edit2Outline, PlayCircleOutline, TrashOutline } from '@/assets' import { Edit2Outline, PlayCircleOutline, TrashOutline } from '@/assets'
import { import {
Button,
Column, Column,
Sort,
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
@@ -43,14 +45,24 @@ type Props = {
decks: Deck[] | undefined decks: Deck[] | undefined
onDeleteClick: (id: string) => void onDeleteClick: (id: string) => void
onEditClick: (id: string) => void onEditClick: (id: string) => void
onSort: (key: Sort) => void
sort: Sort
} }
export const DecksTable = ({ currentUserId, decks, onDeleteClick, onEditClick }: Props) => {
export const DecksTable = ({
currentUserId,
decks,
onDeleteClick,
onEditClick,
onSort,
sort,
}: Props) => {
const handleEditClick = (id: string) => () => onEditClick(id) const handleEditClick = (id: string) => () => onEditClick(id)
const handleDeleteClick = (id: string) => () => onDeleteClick(id) const handleDeleteClick = (id: string) => () => onDeleteClick(id)
return ( return (
<Table> <Table>
<TableHeader columns={columns} /> <TableHeader columns={columns} onSort={onSort} sort={sort} />
<TableBody> <TableBody>
{decks?.map(deck => ( {decks?.map(deck => (
<TableRow key={deck.id}> <TableRow key={deck.id}>
@@ -64,17 +76,17 @@ export const DecksTable = ({ currentUserId, decks, onDeleteClick, onEditClick }:
<TableCell>{deck.author.name}</TableCell> <TableCell>{deck.author.name}</TableCell>
<TableCell> <TableCell>
<div className={s.iconsContainer}> <div className={s.iconsContainer}>
<Link to={`/decks/${deck.id}/learn`}> <Button as={Link} to={`/decks/${deck.id}/learn`} variant={'icon'}>
<PlayCircleOutline /> <PlayCircleOutline />
</Link> </Button>
{deck.author.id === currentUserId && ( {deck.author.id === currentUserId && (
<> <>
<button onClick={handleEditClick(deck.id)}> <Button onClick={handleEditClick(deck.id)} variant={'icon'}>
<Edit2Outline /> <Edit2Outline />
</button> </Button>
<button onClick={handleDeleteClick(deck.id)}> <Button onClick={handleDeleteClick(deck.id)} variant={'icon'}>
<TrashOutline /> <TrashOutline />
</button> </Button>
</> </>
)} )}
</div> </div>

View File

@@ -1,9 +1,10 @@
import { useState } from 'react' import { useState } from 'react'
import { DeleteDeckDialog } from './'
import { Button } from '@/components' import { Button } from '@/components'
import { Meta, StoryObj } from '@storybook/react' import { Meta, StoryObj } from '@storybook/react'
import { DeleteDeckDialog } from './'
const meta = { const meta = {
component: DeleteDeckDialog, component: DeleteDeckDialog,
tags: ['autodocs'], tags: ['autodocs'],

View File

@@ -1,2 +1,2 @@
export * from './decks-table.tsx' export * from './decks-table'
export * from './cards-table.tsx' export * from './cards-table'

View File

@@ -16,13 +16,17 @@ import {
import s from './user-dropdown.module.scss' import s from './user-dropdown.module.scss'
export type UserDropdownProps = { export type UserDropdownProps = {
avatar: string avatar: null | string
email: string email: string
onLogout: ComponentPropsWithoutRef<typeof DropdownMenuItem>['onSelect'] onLogout: ComponentPropsWithoutRef<typeof DropdownMenuItem>['onSelect']
userName: string userName: string
} }
export const UserDropdown = ({ avatar, email, onLogout, userName }: UserDropdownProps) => { export const UserDropdown = ({ avatar, email, onLogout, userName }: UserDropdownProps) => {
if (!avatar) {
avatar = `https://ui-avatars.com/api/?name=${userName.split(' ').join('+')}`
}
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>

View File

@@ -1 +1 @@
export * from './layout.tsx' export * from './layout'

View File

@@ -1,18 +1,18 @@
import type { Meta, StoryObj } from '@storybook/react' import type { Meta, StoryObj } from '@storybook/react'
import { Layout } from './' import { LayoutPrimitive } from './'
const meta = { const meta = {
argTypes: { argTypes: {
onLogout: { action: 'logout' }, onLogout: { action: 'logout' },
}, },
component: Layout, component: LayoutPrimitive,
parameters: { parameters: {
layout: 'fullscreen', layout: 'fullscreen',
}, },
tags: ['autodocs'], tags: ['autodocs'],
title: 'Components/Layout', title: 'Components/Layout',
} satisfies Meta<typeof Layout> } satisfies Meta<typeof LayoutPrimitive>
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>

View File

@@ -1,12 +1,43 @@
import { ReactNode } from 'react' import { ReactNode } from 'react'
import { Outlet, useOutletContext } from 'react-router-dom'
import { Header, HeaderProps } from '@/components' import { Header, HeaderProps, Spinner } from '@/components'
import { useMeQuery } from '@/services/auth/auth.service'
import s from './layout.module.scss' import s from './layout.module.scss'
export type LayoutProps = { children: ReactNode } & HeaderProps type AuthContext = {
isAuthenticated: boolean
}
export const Layout = ({ children, ...headerProps }: LayoutProps) => { export function useAuthContext() {
return useOutletContext<AuthContext>()
}
export const Layout = () => {
const { data, isError, isLoading } = useMeQuery()
const isAuthenticated = !isError && !isLoading
if (isLoading) {
return <Spinner fullScreen />
}
return (
<LayoutPrimitive
avatar={data?.avatar ?? null}
email={data?.email ?? ''}
isLoggedIn={isAuthenticated}
onLogout={() => {}}
userName={data?.name ?? ''}
>
<Outlet context={{ isAuthenticated } satisfies AuthContext} />
</LayoutPrimitive>
)
}
export type LayoutPrimitiveProps = { children: ReactNode } & HeaderProps
export const LayoutPrimitive = ({ children, ...headerProps }: LayoutPrimitiveProps) => {
return ( return (
<div className={s.layout}> <div className={s.layout}>
<Header {...headerProps} /> <Header {...headerProps} />

View File

@@ -1,8 +1,8 @@
import s from './personal-information.module.scss'
import { Camera, Edit, Logout } from '../../../assets/icons' import { Camera, Edit, Logout } from '../../../assets/icons'
import { Button, Card, Typography } from '../../ui' import { Button, Card, Typography } from '../../ui'
import s from './personal-information.module.scss'
type Props = { type Props = {
avatar: string avatar: string
email: string email: string

View File

@@ -44,6 +44,7 @@
} }
.primary { .primary {
composes: button;
color: var(--color-light-100); color: var(--color-light-100);
background-color: var(--color-accent-500); background-color: var(--color-accent-500);
box-shadow: 0 4px 18px rgb(140 97 255 / 35%); box-shadow: 0 4px 18px rgb(140 97 255 / 35%);
@@ -58,6 +59,7 @@
} }
.secondary { .secondary {
composes: button;
color: var(--color-light-100); color: var(--color-light-100);
background-color: var(--color-dark-300); background-color: var(--color-dark-300);
box-shadow: 0 2px 10px 0 #6d6d6d40; box-shadow: 0 2px 10px 0 #6d6d6d40;
@@ -72,6 +74,7 @@
} }
.tertiary { .tertiary {
composes: button;
color: var(--color-accent-500); color: var(--color-accent-500);
background-color: var(--color-dark-900); background-color: var(--color-dark-900);
border: 1px solid var(--color-accent-700); border: 1px solid var(--color-accent-700);
@@ -86,6 +89,8 @@
} }
.link { .link {
composes: button;
padding: 0.375rem 0; padding: 0.375rem 0;
font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold);
@@ -93,3 +98,7 @@
color: var(--color-accent-500); color: var(--color-accent-500);
text-decoration-line: underline; text-decoration-line: underline;
} }
.icon {
border-radius: 9999px;
}

View File

@@ -16,7 +16,7 @@ export type ButtonProps<T extends ElementType = 'button'> = {
children: ReactNode children: ReactNode
className?: string className?: string
fullWidth?: boolean fullWidth?: boolean
variant?: 'link' | 'primary' | 'secondary' | 'tertiary' variant?: 'icon' | 'link' | 'primary' | 'secondary' | 'tertiary'
} & ComponentPropsWithoutRef<T> } & ComponentPropsWithoutRef<T>
const ButtonPolymorph = <T extends ElementType = 'button'>(props: ButtonProps<T>, ref: any) => { const ButtonPolymorph = <T extends ElementType = 'button'>(props: ButtonProps<T>, ref: any) => {
@@ -31,7 +31,7 @@ const ButtonPolymorph = <T extends ElementType = 'button'>(props: ButtonProps<T>
return ( return (
<Component <Component
className={clsx(s.button, s[variant], fullWidth && s.fullWidth, className)} className={clsx(s[variant], fullWidth && s.fullWidth, className)}
{...rest} {...rest}
ref={ref} ref={ref}
/> />

View File

@@ -1,8 +1,9 @@
import type { Meta, StoryObj } from '@storybook/react' import type { Meta, StoryObj } from '@storybook/react'
import { Card } from './'
import { Typography } from '@/components' import { Typography } from '@/components'
import { Card } from './'
const meta = { const meta = {
component: Card, component: Card,
tags: ['autodocs'], tags: ['autodocs'],

View File

@@ -1,7 +1,8 @@
import { useState } from 'react' import { useState } from 'react'
import { Checkbox } from './checkbox'
import { Meta, StoryObj } from '@storybook/react' import { Meta, StoryObj } from '@storybook/react'
import { Checkbox } from './checkbox'
const meta = { const meta = {
component: Checkbox, component: Checkbox,
tags: ['autodocs'], tags: ['autodocs'],

View File

@@ -1,8 +1,9 @@
import { useState } from 'react' import { useState } from 'react'
import { Dialog } from './'
import { Meta, StoryObj } from '@storybook/react' import { Meta, StoryObj } from '@storybook/react'
import { Dialog } from './'
const meta = { const meta = {
component: Dialog, component: Dialog,
tags: ['autodocs'], tags: ['autodocs'],

View File

@@ -1,3 +1,4 @@
export * from './spinner'
export * from './avatar' export * from './avatar'
export * from './dropdown' export * from './dropdown'
export * from '../layout/header' export * from '../layout/header'

View File

@@ -1,6 +1,6 @@
.root { .root {
display: flex; display: flex;
justify-content: center; justify-content: center;
width: 100%;
margin-top: 36px; margin-top: 36px;
padding-inline: 24px;
} }

View File

@@ -1,11 +1,12 @@
import { FC } from 'react' import { ComponentPropsWithoutRef, FC } from 'react'
import { usePagination } from './usePagination'
import { KeyboardArrowLeft, KeyboardArrowRight } from '@/assets' import { KeyboardArrowLeft, KeyboardArrowRight } from '@/assets'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import s from './pagination.module.scss' import s from './pagination.module.scss'
import { usePagination } from './usePagination'
type PaginationConditionals = type PaginationConditionals =
| { | {
onPerPageChange: (itemPerPage: number) => void onPerPageChange: (itemPerPage: number) => void
@@ -26,8 +27,8 @@ export type PaginationProps = {
perPage?: number perPage?: number
perPageOptions?: number[] perPageOptions?: number[]
siblings?: number siblings?: number
} & PaginationConditionals } & PaginationConditionals &
Omit<ComponentPropsWithoutRef<'div'>, 'onChange'>
const classNames = { const classNames = {
container: s.container, container: s.container,
dots: s.dots, dots: s.dots,
@@ -36,12 +37,15 @@ const classNames = {
pageButton(selected?: boolean) { pageButton(selected?: boolean) {
return clsx(this.item, selected && s.selected) return clsx(this.item, selected && s.selected)
}, },
root: s.root, root(className?: string) {
return clsx(s.root, className)
},
select: s.select, select: s.select,
selectBox: s.selectBox, selectBox: s.selectBox,
} }
export const Pagination: FC<PaginationProps> = ({ export const Pagination: FC<PaginationProps> = ({
className,
count, count,
onChange, onChange,
onPerPageChange, onPerPageChange,
@@ -49,6 +53,7 @@ export const Pagination: FC<PaginationProps> = ({
perPage = null, perPage = null,
perPageOptions, perPageOptions,
siblings, siblings,
...rest
}) => { }) => {
const { const {
handleMainPageClicked, handleMainPageClicked,
@@ -67,7 +72,7 @@ export const Pagination: FC<PaginationProps> = ({
const showPerPageSelect = !!perPage && !!perPageOptions && !!onPerPageChange const showPerPageSelect = !!perPage && !!perPageOptions && !!onPerPageChange
return ( return (
<div className={classNames.root}> <div className={classNames.root(className)} {...rest}>
<div className={classNames.container}> <div className={classNames.container}>
<PrevButton disabled={isFirstPage} onClick={handlePreviousPageClicked} /> <PrevButton disabled={isFirstPage} onClick={handlePreviousPageClicked} />

View File

@@ -5,6 +5,20 @@
justify-content: center; justify-content: center;
width: 100%; width: 100%;
min-width: 220px;
}
.valueDisplay {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 44px;
height: 36px;
border: 1px solid var(--color-dark-300);
border-radius: 2px;
} }
.root { .root {
@@ -25,14 +39,14 @@
width: 100%; width: 100%;
height: 4px; height: 4px;
opacity: 0.5; background-color: rgb(140 97 255 / 50%);
background-color: var(--color-accent-500);
border-radius: 2px; border-radius: 2px;
} }
.range { .range {
position: absolute; position: absolute;
height: 100%; height: 100%;
opacity: 1;
background-color: var(--color-accent-500); background-color: var(--color-accent-500);
} }
@@ -40,13 +54,31 @@
touch-action: pan-x; touch-action: pan-x;
cursor: pointer; cursor: pointer;
position: relative;
display: block; display: block;
width: 16px; width: 16px;
height: 16px; height: 16px;
background-color: var(--color-light-100); background-color: var(--color-accent-500);
border-radius: 9999px; border-radius: 9999px;
outline: none;
transition: transform 0.2s ease-in-out; transition: transform 0.2s ease-in-out;
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 8px;
height: 8px;
background-color: var(--color-light-100);
border-radius: 9999px;
}
} }

View File

@@ -0,0 +1,17 @@
import type { Meta, StoryObj } from '@storybook/react'
import { Slider } from './'
const meta = {
component: Slider,
parameters: {},
tags: ['autodocs'],
title: 'Components/Slider',
} satisfies Meta<typeof Slider>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: { value: [0, 100] },
}

View File

@@ -7,7 +7,7 @@ import s from './slider.module.scss'
const Slider = forwardRef< const Slider = forwardRef<
ElementRef<typeof SliderPrimitive.Root>, ElementRef<typeof SliderPrimitive.Root>,
Omit<ComponentPropsWithoutRef<typeof SliderPrimitive.Root>, 'value'> & { Omit<ComponentPropsWithoutRef<typeof SliderPrimitive.Root>, 'value'> & {
value?: (number | undefined)[] value: (null | number)[]
} }
>(({ className, max, onValueChange, value, ...props }, ref) => { >(({ className, max, onValueChange, value, ...props }, ref) => {
useEffect(() => { useEffect(() => {
@@ -18,7 +18,7 @@ const Slider = forwardRef<
return ( return (
<div className={s.container}> <div className={s.container}>
<span>{value?.[0]}</span> <span className={s.valueDisplay}>{value?.[0]}</span>
<SliderPrimitive.Root <SliderPrimitive.Root
className={clsx(s.root, className)} className={clsx(s.root, className)}
max={max} max={max}
@@ -28,12 +28,12 @@ const Slider = forwardRef<
value={[value?.[0] ?? 0, value?.[1] ?? max ?? 0]} value={[value?.[0] ?? 0, value?.[1] ?? max ?? 0]}
> >
<SliderPrimitive.Track className={s.track}> <SliderPrimitive.Track className={s.track}>
<SliderPrimitive.Range className={'absolute h-full bg-primary'} /> <SliderPrimitive.Range className={s.range} />
</SliderPrimitive.Track> </SliderPrimitive.Track>
<SliderPrimitive.Thumb className={s.thumb} /> <SliderPrimitive.Thumb className={s.thumb} />
<SliderPrimitive.Thumb className={s.thumb} /> <SliderPrimitive.Thumb className={s.thumb} />
</SliderPrimitive.Root> </SliderPrimitive.Root>
<span>{value?.[1]}</span> <span className={s.valueDisplay}>{value?.[1]}</span>
</div> </div>
) )
}) })

View File

@@ -0,0 +1 @@
export * from './spinner'

View File

@@ -0,0 +1,57 @@
.fullScreenContainer {
position: fixed;
z-index: 9999;
top: var(--header-height);
left: 0;
display: flex;
align-items: center;
justify-content: center;
width: 100vw;
height: calc(100vh - var(--header-height));
background-color: rgb(0 0 0 / 50%);
}
.loader {
position: relative;
display: inline-block;
box-sizing: border-box;
width: 48px;
height: 48px;
border: 3px solid #fff;
border-radius: 50%;
animation: rotation 1s linear infinite;
}
.loader::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
box-sizing: border-box;
width: 40px;
height: 40px;
border: 3px solid transparent;
border-bottom-color: var(--color-accent-500);
border-radius: 50%;
}
@keyframes rotation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,19 @@
import type { Meta, StoryObj } from '@storybook/react'
import { Spinner } from './'
const meta = {
component: Spinner,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
title: 'Components/Spinner',
} satisfies Meta<typeof Spinner>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {},
}

View File

@@ -0,0 +1,30 @@
import { CSSProperties, ComponentPropsWithoutRef, ElementRef, forwardRef } from 'react'
import { clsx } from 'clsx'
import s from './spinner.module.scss'
export type SpinnerProps = {
fullScreen?: boolean
size?: CSSProperties['width']
} & ComponentPropsWithoutRef<'span'>
export const Spinner = forwardRef<ElementRef<'span'>, SpinnerProps>(
({ className, fullScreen, size = '48px', style, ...rest }, ref) => {
const styles = {
height: size,
width: size,
...style,
} satisfies CSSProperties
if (fullScreen) {
return (
<div className={s.fullScreenContainer}>
<span className={clsx(s.loader, className)} ref={ref} style={styles} {...rest} />
</div>
)
}
return <span className={clsx(s.loader, className)} ref={ref} style={styles} {...rest} />
}
)

View File

@@ -1,7 +1,8 @@
import { Table, TableBody, TableCell, TableEmpty, TableHead, TableHeadCell, TableRow } from './'
import { Typography } from '@/components' import { Typography } from '@/components'
import { Meta } from '@storybook/react' import { Meta } from '@storybook/react'
import { Table, TableBody, TableCell, TableEmpty, TableHead, TableHeadCell, TableRow } from './'
export default { export default {
component: Table, component: Table,
title: 'Components/Table', title: 'Components/Table',

View File

@@ -1,5 +1,6 @@
.list { .list {
display: inline-flex; display: inline-flex;
flex-shrink: 0;
background-color: var(--color-dark-900); background-color: var(--color-dark-900);
} }

View File

@@ -1,5 +1,6 @@
.root { .root {
width: 100%; width: 100%;
min-width: 200px;
} }
.fieldContainer { .fieldContainer {

1
src/hooks/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './use-query-param'

View File

@@ -0,0 +1 @@
export * from './use-query-param'

View File

@@ -0,0 +1,36 @@
import { isNil } from 'remeda'
export function useQueryParam<T extends boolean | number | string>(
searchParams: URLSearchParams,
setSearchParams: (searchParams: URLSearchParams) => void,
param: string,
defaultValue?: T
): [T | null, (value: T | null) => void] {
const paramValue = searchParams.get(param)
const convertedValue = getConvertedValue<T>(paramValue, defaultValue)
const setParamValue = (value: T | null): void => {
if (isNil(value) || value === '') {
searchParams.delete(param)
} else {
searchParams.set(param, String(value))
}
setSearchParams(searchParams)
}
return [convertedValue, setParamValue]
}
function getConvertedValue<T>(value: null | string, defaultValue: T | undefined): T | null {
if (value === null) {
return defaultValue ?? null
}
if (value === 'true' || value === 'false') {
return (value === 'true') as unknown as T
}
if (!isNaN(Number(value))) {
return Number(value) as unknown as T
}
return value as unknown as T
}

View File

@@ -1,11 +1,12 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { App } from './App'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './styles/index.scss'
import '@fontsource/roboto/400.css' import '@fontsource/roboto/400.css'
import '@fontsource/roboto/700.css' import '@fontsource/roboto/700.css'
import './styles/index.scss'
import { App } from './App'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>

View File

@@ -13,5 +13,9 @@
display: flex; display: flex;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
column-gap: 16px; column-gap: 16px;
margin-bottom: 16px; margin: 10px 0 16px;
}
.pagination {
margin-top: 24px;
} }

View File

@@ -1,69 +1,65 @@
import { useState } from 'react' import { useState } from 'react'
import { Button, DecksTable, Page, Slider, TextField, Typography } from '@/components' import { Button, DecksTable, Page, Slider, Spinner, TextField, Typography } from '@/components'
import { DeckDialog } from '@/components/decks/deck-dialog' import { DeckDialog } from '@/components/decks/deck-dialog'
import { DeleteDeckDialog } from '@/components/decks/delete-deck-dialog' import { DeleteDeckDialog } from '@/components/decks/delete-deck-dialog'
import { Pagination } from '@/components/ui/pagination' import { Pagination } from '@/components/ui/pagination'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useDeckSearchParams } from '@/pages/decks-page/use-deck-search-params'
import { useMeQuery } from '@/services/auth/auth.service'
import { import {
Tab,
useCreateDeckMutation, useCreateDeckMutation,
useDeleteDeckMutation, useDeleteDeckMutation,
useGetDecksQuery, useGetDecksQuery,
useUpdateDeckMutation, useUpdateDeckMutation,
} from '@/services/decks' } from '@/services/decks'
import {
selectDecksCurrentPage,
selectDecksCurrentTab,
selectDecksMaxCards,
selectDecksMinCards,
selectDecksSearch,
} from '@/services/decks/decks.selectors'
import { decksSlice } from '@/services/decks/decks.slice'
import { useAppDispatch, useAppSelector } from '@/services/store'
import s from './decks-page.module.scss' import s from './decks-page.module.scss'
export const DecksPage = () => { export const DecksPage = () => {
const { data: me } = useMeQuery()
const [showCreateModal, setShowCreateModal] = useState(false) const [showCreateModal, setShowCreateModal] = useState(false)
const [deckToDeleteId, setDeckToDeleteId] = useState<null | string>(null) const [deckToDeleteId, setDeckToDeleteId] = useState<null | string>(null)
const [deckToEditId, setDeckToEditId] = useState<null | string>(null) const [deckToEditId, setDeckToEditId] = useState<null | string>(null)
const showEditModal = !!deckToEditId const showEditModal = !!deckToEditId
const dispatch = useAppDispatch() const {
const currentPage = useAppSelector(selectDecksCurrentPage) currentPage,
const minCards = useAppSelector(selectDecksMinCards) currentTab,
const maxCards = useAppSelector(selectDecksMaxCards) maxCardsCount,
const currentTab = useAppSelector(selectDecksCurrentTab) minCardsCount,
const search = useAppSelector(selectDecksSearch) rangeValue,
const setCurrentPage = (page: number) => dispatch(decksSlice.actions.setCurrentPage(page)) search,
const setMinCards = (minCards: number) => dispatch(decksSlice.actions.setMinCards(minCards)) setCurrentPage,
const setMaxCards = (maxCards: number) => dispatch(decksSlice.actions.setMaxCards(maxCards)) setCurrentTab,
const setSearch = (search: string) => dispatch(decksSlice.actions.setSearch(search)) setMaxCards,
const setCurrentTab = (tab: Tab) => dispatch(decksSlice.actions.setCurrentTab(tab)) setMinCards,
setRangeValue,
setSearch,
setSort,
sort,
} = useDeckSearchParams()
const resetFilters = () => { const currentUserId = me?.id
dispatch(decksSlice.actions.resetFilters())
setRangeValue([0, decks?.maxCardsCount || undefined])
}
const [rangeValue, setRangeValue] = useState([minCards, maxCards])
const handleSliderCommitted = (value: number[]) => {
setMinCards(value[0])
setMaxCards(value[1])
}
const currentUserId = 'f2be95b9-4d07-4751-a775-bd612fc9553a'
const authorId = currentTab === 'my' ? currentUserId : undefined const authorId = currentTab === 'my' ? currentUserId : undefined
const { currentData: decksCurrentData, data: decksData } = useGetDecksQuery({
const { data: decks } = useGetDecksQuery({
authorId, authorId,
currentPage, currentPage,
maxCardsCount: maxCards, maxCardsCount,
minCardsCount: minCards, minCardsCount,
name: search, name: search,
orderBy: sort ? `${sort.key}-${sort.direction}` : undefined,
}) })
const resetFilters = () => {
setCurrentPage(null)
setSearch(null)
setMinCards(null)
setMaxCards(null)
setRangeValue([0, decks?.maxCardsCount ?? null])
setSort(null)
}
const decks = decksCurrentData ?? decksData
const showConfirmDeleteModal = !!deckToDeleteId const showConfirmDeleteModal = !!deckToDeleteId
const deckToDeleteName = decks?.items?.find(deck => deck.id === deckToDeleteId)?.name const deckToDeleteName = decks?.items?.find(deck => deck.id === deckToDeleteId)?.name
@@ -73,10 +69,26 @@ export const DecksPage = () => {
const [createDeck] = useCreateDeckMutation() const [createDeck] = useCreateDeckMutation()
const [deleteDeck] = useDeleteDeckMutation() const [deleteDeck] = useDeleteDeckMutation()
const [updateDeck] = useUpdateDeckMutation() const [updateDeck] = useUpdateDeckMutation()
const openCreateModal = () => setShowCreateModal(true) const openCreateModal = () => setShowCreateModal(true)
if (!decks) { const handleSearch = (search: null | string) => {
return <div>loading...</div> setCurrentPage(null)
setSearch(search)
}
const handleSliderCommitted = (value: number[]) => {
setCurrentPage(null)
setMinCards(value[0])
setMaxCards(value[1])
}
const handleTabChange = (tab: string) => {
setCurrentPage(null)
setCurrentTab(tab)
}
if (!decks || !me) {
return <Spinner fullScreen />
} }
return ( return (
@@ -110,14 +122,22 @@ export const DecksPage = () => {
<Button onClick={openCreateModal}>Add new deck</Button> <Button onClick={openCreateModal}>Add new deck</Button>
<DeckDialog <DeckDialog
onCancel={() => setShowCreateModal(false)} onCancel={() => setShowCreateModal(false)}
onConfirm={createDeck} onConfirm={data => {
resetFilters()
createDeck(data)
}}
onOpenChange={setShowCreateModal} onOpenChange={setShowCreateModal}
open={showCreateModal} open={showCreateModal}
/> />
</div> </div>
<div className={s.filters}> <div className={s.filters}>
<TextField onValueChange={setSearch} placeholder={'Search'} search value={search} /> <TextField
<Tabs onValueChange={value => setCurrentTab(value as Tab)} value={currentTab}> onValueChange={handleSearch}
placeholder={'Search'}
search
value={search ?? ''}
/>
<Tabs asChild onValueChange={handleTabChange} value={currentTab ?? undefined}>
<TabsList> <TabsList>
<TabsTrigger value={'my'}>My decks</TabsTrigger> <TabsTrigger value={'my'}>My decks</TabsTrigger>
<TabsTrigger value={'all'}>All decks</TabsTrigger> <TabsTrigger value={'all'}>All decks</TabsTrigger>
@@ -135,15 +155,18 @@ export const DecksPage = () => {
</Button> </Button>
</div> </div>
<DecksTable <DecksTable
currentUserId={currentUserId} currentUserId={currentUserId ?? ''}
decks={decks?.items} decks={decks?.items}
onDeleteClick={setDeckToDeleteId} onDeleteClick={setDeckToDeleteId}
onEditClick={setDeckToEditId} onEditClick={setDeckToEditId}
onSort={setSort}
sort={sort}
/> />
<Pagination <Pagination
className={s.pagination}
count={decks?.pagination?.totalPages || 1} count={decks?.pagination?.totalPages || 1}
onChange={setCurrentPage} onChange={setCurrentPage}
page={currentPage} page={currentPage ?? 1}
/> />
</div> </div>
</Page> </Page>

View File

@@ -0,0 +1,75 @@
import { useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Sort } from '@/components'
import { useQueryParam } from '@/hooks'
export function useDeckSearchParams() {
const [searchParams, setSearchParams] = useSearchParams()
const [currentPage, setCurrentPage] = useQueryParam<number>(
searchParams,
setSearchParams,
'page',
1
)
const [minCardsCount, setMinCards] = useQueryParam<number>(
searchParams,
setSearchParams,
'minCards',
0
)
const [maxCardsCount, setMaxCards] = useQueryParam<number>(
searchParams,
setSearchParams,
'maxCards'
)
const [search, setSearch] = useQueryParam<string>(searchParams, setSearchParams, 'search')
const [currentTab, setCurrentTab] = useQueryParam<string>(
searchParams,
setSearchParams,
'currentTab',
'all'
)
const [rangeValue, setRangeValue] = useState([minCardsCount, maxCardsCount])
const [sortKey, setSortKey] = useQueryParam<string>(searchParams, setSearchParams, 'sortKey')
const [sortDirection, setSortDirection] = useQueryParam<'asc' | 'desc'>(
searchParams,
setSearchParams,
'sortDirection'
)
const setSort = (sort: Sort) => {
if (!sort) {
setSortKey(null)
setSortDirection(null)
return
}
setSortKey(sort.key)
setSortDirection(sort.direction)
}
const sort: Sort =
sortDirection === null || sortKey === null
? null
: {
direction: sortDirection,
key: sortKey,
}
return {
currentPage,
currentTab,
maxCardsCount,
minCardsCount,
rangeValue,
search,
setCurrentPage,
setCurrentTab,
setMaxCards,
setMinCards,
setRangeValue,
setSearch,
setSort,
sort,
}
}

View File

@@ -1,9 +1,26 @@
import { useNavigate } from 'react-router-dom'
import { toast } from 'react-toastify'
import { Page, SignIn } from '@/components' import { Page, SignIn } from '@/components'
import { useLoginMutation } from '@/services/auth/auth.service'
import { LoginArgs } from '@/services/auth/auth.types'
export const SignInPage = () => { export const SignInPage = () => {
const [signIn] = useLoginMutation()
const navigate = useNavigate()
const handleSignIn = async (data: LoginArgs) => {
try {
await signIn(data).unwrap()
navigate('/')
} catch (error: any) {
console.log(error)
toast.error(error?.data?.message ?? 'Could not sign in')
}
}
return ( return (
<Page> <Page>
<SignIn onSubmit={() => {}} /> <SignIn onSubmit={handleSignIn} />
</Page> </Page>
) )
} }

View File

@@ -6,9 +6,11 @@ import {
createBrowserRouter, createBrowserRouter,
} from 'react-router-dom' } from 'react-router-dom'
import { DecksPage, SignInPage } from './pages' import { Layout, useAuthContext } from '@/components/layout'
import { DeckPage } from '@/pages/deck-page/deck-page' import { DeckPage } from '@/pages/deck-page/deck-page'
import { DecksPage, SignInPage } from './pages'
const publicRoutes: RouteObject[] = [ const publicRoutes: RouteObject[] = [
{ {
children: [ children: [
@@ -33,11 +35,16 @@ const privateRoutes: RouteObject[] = [
] ]
const router = createBrowserRouter([ const router = createBrowserRouter([
{
children: [
{ {
children: privateRoutes, children: privateRoutes,
element: <PrivateRoutes />, element: <PrivateRoutes />,
}, },
...publicRoutes, ...publicRoutes,
],
element: <Layout />,
},
]) ])
export const Router = () => { export const Router = () => {
@@ -45,7 +52,7 @@ export const Router = () => {
} }
function PrivateRoutes() { function PrivateRoutes() {
const isAuthenticated = true const { isAuthenticated } = useAuthContext()
return isAuthenticated ? <Outlet /> : <Navigate to={'/login'} /> return isAuthenticated ? <Outlet /> : <Navigate to={'/login'} />
} }

View File

@@ -0,0 +1,21 @@
import { baseApi } from '..'
import { LoginArgs, User } from './auth.types'
export const authService = baseApi.injectEndpoints({
endpoints: builder => ({
login: builder.mutation<void, LoginArgs>({
invalidatesTags: ['Me'],
query: body => ({
body,
method: 'POST',
url: '/v1/auth/login',
}),
}),
me: builder.query<User, void>({
providesTags: ['Me'],
query: () => '/v1/auth/me',
}),
}),
})
export const { useLoginMutation, useMeQuery } = authService

View File

@@ -0,0 +1,14 @@
export type LoginArgs = {
email: string
password: string
rememberMe?: boolean
}
export type User = {
avatar: null | string
created: string
email: string
id: string
isEmailVerified: boolean
name: string
updated: string
}

View File

@@ -1,14 +1,9 @@
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' import { baseQueryWithReauth } from '@/services/base-query-with-reauth'
import { createApi } from '@reduxjs/toolkit/query/react'
export const baseApi = createApi({ export const baseApi = createApi({
baseQuery: fetchBaseQuery({ baseQuery: baseQueryWithReauth,
baseUrl: 'https://api.flashcards.andrii.es',
credentials: 'include',
prepareHeaders: headers => {
headers.append('x-auth-skip', 'true')
},
}),
endpoints: () => ({}), endpoints: () => ({}),
reducerPath: 'baseApi', reducerPath: 'baseApi',
tagTypes: ['Decks'], tagTypes: ['Decks', 'Me'],
}) })

View File

@@ -0,0 +1,47 @@
import {
BaseQueryFn,
FetchArgs,
FetchBaseQueryError,
fetchBaseQuery,
} from '@reduxjs/toolkit/query/react'
import { Mutex } from 'async-mutex'
const mutex = new Mutex()
const baseQuery = fetchBaseQuery({
baseUrl: 'https://api.flashcards.andrii.es',
credentials: 'include',
})
export const baseQueryWithReauth: BaseQueryFn<
FetchArgs | string,
unknown,
FetchBaseQueryError
> = async (args, api, extraOptions) => {
await mutex.waitForUnlock()
let result = await baseQuery(args, api, extraOptions)
if (result.error && result.error.status === 401) {
if (!mutex.isLocked()) {
const release = await mutex.acquire()
// try to get a new token
const refreshResult = await baseQuery(
{ method: 'POST', url: '/v1/auth/refresh-token' },
api,
extraOptions
)
if (refreshResult.meta?.response?.status === 204) {
// retry the initial query
result = await baseQuery(args, api, extraOptions)
}
release()
} else {
// wait until the mutex is available without locking it
await mutex.waitForUnlock()
result = await baseQuery(args, api, extraOptions)
}
}
return result
}

View File

@@ -1,4 +1,4 @@
import { RootState } from '@/services/store.ts' import { RootState } from '@/services/store'
export const selectDecksCurrentPage = (state: RootState) => state.decks.currentPage export const selectDecksCurrentPage = (state: RootState) => state.decks.currentPage

View File

@@ -7,11 +7,30 @@ import {
UpdateDeckArgs, UpdateDeckArgs,
baseApi, baseApi,
} from '@/services' } from '@/services'
import { RootState } from '@/services/store'
import { getValuable } from '@/utils'
const decksService = baseApi.injectEndpoints({ const decksService = baseApi.injectEndpoints({
endpoints: builder => ({ endpoints: builder => ({
createDeck: builder.mutation<DeckResponse, CreateDeckArgs>({ createDeck: builder.mutation<DeckResponse, CreateDeckArgs>({
invalidatesTags: ['Decks'], invalidatesTags: ['Decks'],
async onQueryStarted(_, { dispatch, getState, queryFulfilled }) {
const res = await queryFulfilled
for (const { endpointName, originalArgs } of decksService.util.selectInvalidatedBy(
getState(),
[{ type: 'Decks' }]
)) {
if (endpointName !== 'getDecks') {
continue
}
dispatch(
decksService.util.updateQueryData(endpointName, originalArgs, draft => {
draft.items.unshift(res.data)
})
)
}
},
query: body => ({ query: body => ({
body, body,
method: 'POST', method: 'POST',
@@ -35,13 +54,49 @@ const decksService = baseApi.injectEndpoints({
providesTags: ['Decks'], providesTags: ['Decks'],
query: args => { query: args => {
return { return {
params: args ?? undefined, params: args ? getValuable(args) : undefined,
url: `v1/decks`, url: `v1/decks`,
} }
}, },
}), }),
updateDeck: builder.mutation<DeckResponse, UpdateDeckArgs>({ updateDeck: builder.mutation<DeckResponse, UpdateDeckArgs>({
invalidatesTags: ['Decks'], invalidatesTags: ['Decks'],
async onQueryStarted({ id, ...patch }, { dispatch, getState, queryFulfilled }) {
const state = getState() as RootState
const minCardsCount = state.decks.minCards
const search = state.decks.search
const currentPage = state.decks.currentPage
const maxCardsCount = state.decks.maxCards
const authorId = state.decks.authorId
const patchResult = dispatch(
decksService.util.updateQueryData(
'getDecks',
{
authorId,
currentPage,
maxCardsCount,
minCardsCount,
name: search,
},
draft => {
const deck = draft.items.find(deck => deck.id === id)
if (!deck) {
return
}
Object.assign(deck, patch)
}
)
)
try {
await queryFulfilled
} catch {
patchResult.undo()
}
},
query: ({ id, ...body }) => ({ query: ({ id, ...body }) => ({
body, body,
method: 'PATCH', method: 'PATCH',

View File

@@ -3,6 +3,7 @@ import { PayloadAction, createSlice } from '@reduxjs/toolkit'
export const decksSlice = createSlice({ export const decksSlice = createSlice({
initialState: { initialState: {
authorId: undefined as string | undefined,
currentPage: 1, currentPage: 1,
currentTab: 'all' as Tab, currentTab: 'all' as Tab,
maxCards: undefined as number | undefined, maxCards: undefined as number | undefined,
@@ -18,14 +19,17 @@ export const decksSlice = createSlice({
resetFilters: state => { resetFilters: state => {
state.search = '' state.search = ''
state.currentTab = 'all' state.currentTab = 'all'
state.authorId = undefined
state.minCards = 0 state.minCards = 0
state.maxCards = undefined state.maxCards = undefined
state.currentPage = 1
}, },
setCurrentPage: (state, action: PayloadAction<number>) => { setCurrentPage: (state, action: PayloadAction<number>) => {
state.currentPage = action.payload state.currentPage = action.payload
}, },
setCurrentTab: (state, action: PayloadAction<Tab>) => { setCurrentTab: (state, action: PayloadAction<{ authorId?: string; tab: Tab }>) => {
state.currentTab = action.payload state.currentTab = action.payload.tab
state.authorId = action.payload.authorId
}, },
setMaxCards: (state, action: PayloadAction<number>) => { setMaxCards: (state, action: PayloadAction<number>) => {
state.maxCards = action.payload state.maxCards = action.payload

View File

@@ -54,13 +54,13 @@ export type Card = {
} }
export type GetDecksArgs = { export type GetDecksArgs = {
authorId?: string authorId?: null | string
currentPage?: number currentPage?: null | number
itemsPerPage?: number itemsPerPage?: null | number
maxCardsCount?: number maxCardsCount?: null | number
minCardsCount?: number minCardsCount?: null | number
name?: string name?: null | string
orderBy?: string orderBy?: null | string
} }
export type CreateDeckArgs = { export type CreateDeckArgs = {

View File

@@ -1,10 +1,11 @@
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux' import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
import { baseApi } from './base-api'
import { decksSlice } from '@/services/decks/decks.slice' import { decksSlice } from '@/services/decks/decks.slice'
import { configureStore } from '@reduxjs/toolkit' import { configureStore } from '@reduxjs/toolkit'
import { setupListeners } from '@reduxjs/toolkit/query/react' import { setupListeners } from '@reduxjs/toolkit/query/react'
import { baseApi } from './base-api'
export const store = configureStore({ export const store = configureStore({
middleware: getDefaultMiddleware => getDefaultMiddleware().concat(baseApi.middleware), middleware: getDefaultMiddleware => getDefaultMiddleware().concat(baseApi.middleware),
reducer: { reducer: {

View File

@@ -41,6 +41,8 @@ select,
textarea, textarea,
optgroup, optgroup,
option { option {
box-sizing: border-box;
font-family: inherit; font-family: inherit;
font-size: inherit; font-size: inherit;
font-weight: inherit; font-weight: inherit;

View File

@@ -0,0 +1,9 @@
type Valuable<T> = { [K in keyof T as T[K] extends null | undefined ? never : K]: T[K] }
export function getValuable<T extends {}, V = Valuable<T>>(obj: T): V {
return Object.fromEntries(
Object.entries(obj).filter(
([, v]) => !((typeof v === 'string' && !v.length) || v === null || typeof v === 'undefined')
)
) as V
}

View File

@@ -1 +1,2 @@
export * from './date' export * from './date'
export * from './get-valuable'

View File

@@ -8,7 +8,7 @@
/* Bundler mode */ /* Bundler mode */
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": false,
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"noEmit": true, "noEmit": true,