mirror of
https://github.com/ershisan99/flashcards-example-project.git
synced 2025-12-18 05:09:23 +00:00
Merge master into storybook-deploy
This commit is contained in:
@@ -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
17
pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export * from './deck-dialog.tsx'
|
export * from './deck-dialog'
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export * from './decks-table.tsx'
|
export * from './decks-table'
|
||||||
export * from './cards-table.tsx'
|
export * from './cards-table'
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export * from './layout.tsx'
|
export * from './layout'
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
17
src/components/ui/slider/slider.stories.tsx
Normal file
17
src/components/ui/slider/slider.stories.tsx
Normal 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] },
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
1
src/components/ui/spinner/index.ts
Normal file
1
src/components/ui/spinner/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './spinner'
|
||||||
57
src/components/ui/spinner/spinner.module.scss
Normal file
57
src/components/ui/spinner/spinner.module.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/components/ui/spinner/spinner.stories.tsx
Normal file
19
src/components/ui/spinner/spinner.stories.tsx
Normal 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: {},
|
||||||
|
}
|
||||||
30
src/components/ui/spinner/spinner.tsx
Normal file
30
src/components/ui/spinner/spinner.tsx
Normal 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} />
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
.root {
|
.root {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fieldContainer {
|
.fieldContainer {
|
||||||
|
|||||||
1
src/hooks/index.ts
Normal file
1
src/hooks/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './use-query-param'
|
||||||
1
src/hooks/use-query-param/index.ts
Normal file
1
src/hooks/use-query-param/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './use-query-param'
|
||||||
36
src/hooks/use-query-param/use-query-param.ts
Normal file
36
src/hooks/use-query-param/use-query-param.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
75
src/pages/decks-page/use-deck-search-params.ts
Normal file
75
src/pages/decks-page/use-deck-search-params.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: [
|
||||||
@@ -34,10 +36,15 @@ const privateRoutes: RouteObject[] = [
|
|||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
children: privateRoutes,
|
children: [
|
||||||
element: <PrivateRoutes />,
|
{
|
||||||
|
children: privateRoutes,
|
||||||
|
element: <PrivateRoutes />,
|
||||||
|
},
|
||||||
|
...publicRoutes,
|
||||||
|
],
|
||||||
|
element: <Layout />,
|
||||||
},
|
},
|
||||||
...publicRoutes,
|
|
||||||
])
|
])
|
||||||
|
|
||||||
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'} />
|
||||||
}
|
}
|
||||||
|
|||||||
21
src/services/auth/auth.service.ts
Normal file
21
src/services/auth/auth.service.ts
Normal 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
|
||||||
14
src/services/auth/auth.types.ts
Normal file
14
src/services/auth/auth.types.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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'],
|
||||||
})
|
})
|
||||||
|
|||||||
47
src/services/base-query-with-reauth.ts
Normal file
47
src/services/base-query-with-reauth.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
9
src/utils/get-valuable.ts
Normal file
9
src/utils/get-valuable.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
export * from './date'
|
export * from './date'
|
||||||
|
export * from './get-valuable'
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user