diff --git a/src/components/profile/personal-information/personal-information.tsx b/src/components/profile/personal-information/personal-information.tsx
index 042b931..2803672 100644
--- a/src/components/profile/personal-information/personal-information.tsx
+++ b/src/components/profile/personal-information/personal-information.tsx
@@ -1,8 +1,8 @@
+import s from './personal-information.module.scss'
+
import { Camera, Edit, Logout } from '../../../assets/icons'
import { Button, Card, Typography } from '../../ui'
-import s from './personal-information.module.scss'
-
type Props = {
avatar: string
email: string
diff --git a/src/components/ui/button/button.module.scss b/src/components/ui/button/button.module.scss
index e9540ca..d82cdf3 100644
--- a/src/components/ui/button/button.module.scss
+++ b/src/components/ui/button/button.module.scss
@@ -44,6 +44,7 @@
}
.primary {
+ composes: button;
color: var(--color-light-100);
background-color: var(--color-accent-500);
box-shadow: 0 4px 18px rgb(140 97 255 / 35%);
@@ -58,6 +59,7 @@
}
.secondary {
+ composes: button;
color: var(--color-light-100);
background-color: var(--color-dark-300);
box-shadow: 0 2px 10px 0 #6d6d6d40;
@@ -72,6 +74,7 @@
}
.tertiary {
+ composes: button;
color: var(--color-accent-500);
background-color: var(--color-dark-900);
border: 1px solid var(--color-accent-700);
@@ -86,6 +89,8 @@
}
.link {
+ composes: button;
+
padding: 0.375rem 0;
font-weight: var(--font-weight-bold);
@@ -93,3 +98,7 @@
color: var(--color-accent-500);
text-decoration-line: underline;
}
+
+.icon {
+ border-radius: 9999px;
+}
diff --git a/src/components/ui/button/button.tsx b/src/components/ui/button/button.tsx
index 861fd4c..dbcad6e 100644
--- a/src/components/ui/button/button.tsx
+++ b/src/components/ui/button/button.tsx
@@ -16,7 +16,7 @@ export type ButtonProps
= {
children: ReactNode
className?: string
fullWidth?: boolean
- variant?: 'link' | 'primary' | 'secondary' | 'tertiary'
+ variant?: 'icon' | 'link' | 'primary' | 'secondary' | 'tertiary'
} & ComponentPropsWithoutRef
const ButtonPolymorph = (props: ButtonProps, ref: any) => {
@@ -31,7 +31,7 @@ const ButtonPolymorph = (props: ButtonProps
return (
diff --git a/src/components/ui/card/card.stories.tsx b/src/components/ui/card/card.stories.tsx
index 04f9afc..cc0656a 100644
--- a/src/components/ui/card/card.stories.tsx
+++ b/src/components/ui/card/card.stories.tsx
@@ -1,8 +1,9 @@
import type { Meta, StoryObj } from '@storybook/react'
-import { Card } from './'
import { Typography } from '@/components'
+import { Card } from './'
+
const meta = {
component: Card,
tags: ['autodocs'],
diff --git a/src/components/ui/checkbox/checkbox.stories.tsx b/src/components/ui/checkbox/checkbox.stories.tsx
index 6e50671..00bfbad 100644
--- a/src/components/ui/checkbox/checkbox.stories.tsx
+++ b/src/components/ui/checkbox/checkbox.stories.tsx
@@ -1,7 +1,8 @@
import { useState } from 'react'
-import { Checkbox } from './checkbox'
import { Meta, StoryObj } from '@storybook/react'
+
+import { Checkbox } from './checkbox'
const meta = {
component: Checkbox,
tags: ['autodocs'],
diff --git a/src/components/ui/dialog/dialog.stories.tsx b/src/components/ui/dialog/dialog.stories.tsx
index 854d88f..b133210 100644
--- a/src/components/ui/dialog/dialog.stories.tsx
+++ b/src/components/ui/dialog/dialog.stories.tsx
@@ -1,8 +1,9 @@
import { useState } from 'react'
-import { Dialog } from './'
import { Meta, StoryObj } from '@storybook/react'
+import { Dialog } from './'
+
const meta = {
component: Dialog,
tags: ['autodocs'],
diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts
index 594e76e..cf20b41 100644
--- a/src/components/ui/index.ts
+++ b/src/components/ui/index.ts
@@ -1,3 +1,4 @@
+export * from './spinner'
export * from './avatar'
export * from './dropdown'
export * from '../layout/header'
diff --git a/src/components/ui/page/page.module.scss b/src/components/ui/page/page.module.scss
index 077066d..d659099 100644
--- a/src/components/ui/page/page.module.scss
+++ b/src/components/ui/page/page.module.scss
@@ -1,6 +1,6 @@
.root {
display: flex;
justify-content: center;
+ width: 100%;
margin-top: 36px;
- padding-inline: 24px;
}
diff --git a/src/components/ui/pagination/pagination.tsx b/src/components/ui/pagination/pagination.tsx
index eab5115..ceb3803 100644
--- a/src/components/ui/pagination/pagination.tsx
+++ b/src/components/ui/pagination/pagination.tsx
@@ -1,11 +1,12 @@
-import { FC } from 'react'
+import { ComponentPropsWithoutRef, FC } from 'react'
-import { usePagination } from './usePagination'
import { KeyboardArrowLeft, KeyboardArrowRight } from '@/assets'
import { clsx } from 'clsx'
import s from './pagination.module.scss'
+import { usePagination } from './usePagination'
+
type PaginationConditionals =
| {
onPerPageChange: (itemPerPage: number) => void
@@ -26,8 +27,8 @@ export type PaginationProps = {
perPage?: number
perPageOptions?: number[]
siblings?: number
-} & PaginationConditionals
-
+} & PaginationConditionals &
+ Omit, 'onChange'>
const classNames = {
container: s.container,
dots: s.dots,
@@ -36,12 +37,15 @@ const classNames = {
pageButton(selected?: boolean) {
return clsx(this.item, selected && s.selected)
},
- root: s.root,
+ root(className?: string) {
+ return clsx(s.root, className)
+ },
select: s.select,
selectBox: s.selectBox,
}
export const Pagination: FC = ({
+ className,
count,
onChange,
onPerPageChange,
@@ -49,6 +53,7 @@ export const Pagination: FC = ({
perPage = null,
perPageOptions,
siblings,
+ ...rest
}) => {
const {
handleMainPageClicked,
@@ -67,7 +72,7 @@ export const Pagination: FC = ({
const showPerPageSelect = !!perPage && !!perPageOptions && !!onPerPageChange
return (
-
+
diff --git a/src/components/ui/slider/slider.module.scss b/src/components/ui/slider/slider.module.scss
index e00a847..dc1c449 100644
--- a/src/components/ui/slider/slider.module.scss
+++ b/src/components/ui/slider/slider.module.scss
@@ -5,6 +5,20 @@
justify-content: center;
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 {
@@ -25,14 +39,14 @@
width: 100%;
height: 4px;
- opacity: 0.5;
- background-color: var(--color-accent-500);
+ background-color: rgb(140 97 255 / 50%);
border-radius: 2px;
}
.range {
position: absolute;
height: 100%;
+ opacity: 1;
background-color: var(--color-accent-500);
}
@@ -40,13 +54,31 @@
touch-action: pan-x;
cursor: pointer;
+ position: relative;
+
display: block;
width: 16px;
height: 16px;
- background-color: var(--color-light-100);
+ background-color: var(--color-accent-500);
border-radius: 9999px;
+ outline: none;
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;
+ }
}
diff --git a/src/components/ui/slider/slider.stories.tsx b/src/components/ui/slider/slider.stories.tsx
new file mode 100644
index 0000000..4105675
--- /dev/null
+++ b/src/components/ui/slider/slider.stories.tsx
@@ -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
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: { value: [0, 100] },
+}
diff --git a/src/components/ui/slider/slider.tsx b/src/components/ui/slider/slider.tsx
index 99d7ebe..520bec1 100644
--- a/src/components/ui/slider/slider.tsx
+++ b/src/components/ui/slider/slider.tsx
@@ -7,7 +7,7 @@ import s from './slider.module.scss'
const Slider = forwardRef<
ElementRef,
Omit, 'value'> & {
- value?: (number | undefined)[]
+ value: (null | number)[]
}
>(({ className, max, onValueChange, value, ...props }, ref) => {
useEffect(() => {
@@ -18,7 +18,7 @@ const Slider = forwardRef<
return (
- {value?.[0]}
+ {value?.[0]}
-
+
- {value?.[1]}
+ {value?.[1]}
)
})
diff --git a/src/components/ui/spinner/index.ts b/src/components/ui/spinner/index.ts
new file mode 100644
index 0000000..21591ec
--- /dev/null
+++ b/src/components/ui/spinner/index.ts
@@ -0,0 +1 @@
+export * from './spinner'
diff --git a/src/components/ui/spinner/spinner.module.scss b/src/components/ui/spinner/spinner.module.scss
new file mode 100644
index 0000000..480017c
--- /dev/null
+++ b/src/components/ui/spinner/spinner.module.scss
@@ -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);
+ }
+}
diff --git a/src/components/ui/spinner/spinner.stories.tsx b/src/components/ui/spinner/spinner.stories.tsx
new file mode 100644
index 0000000..038a9e3
--- /dev/null
+++ b/src/components/ui/spinner/spinner.stories.tsx
@@ -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
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {},
+}
diff --git a/src/components/ui/spinner/spinner.tsx b/src/components/ui/spinner/spinner.tsx
new file mode 100644
index 0000000..ff1a335
--- /dev/null
+++ b/src/components/ui/spinner/spinner.tsx
@@ -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, SpinnerProps>(
+ ({ className, fullScreen, size = '48px', style, ...rest }, ref) => {
+ const styles = {
+ height: size,
+ width: size,
+ ...style,
+ } satisfies CSSProperties
+
+ if (fullScreen) {
+ return (
+
+
+
+ )
+ }
+
+ return
+ }
+)
diff --git a/src/components/ui/table/table.stories.tsx b/src/components/ui/table/table.stories.tsx
index 9ea1833..01f1efd 100644
--- a/src/components/ui/table/table.stories.tsx
+++ b/src/components/ui/table/table.stories.tsx
@@ -1,7 +1,8 @@
-import { Table, TableBody, TableCell, TableEmpty, TableHead, TableHeadCell, TableRow } from './'
import { Typography } from '@/components'
import { Meta } from '@storybook/react'
+import { Table, TableBody, TableCell, TableEmpty, TableHead, TableHeadCell, TableRow } from './'
+
export default {
component: Table,
title: 'Components/Table',
diff --git a/src/components/ui/tabs/tabs.module.scss b/src/components/ui/tabs/tabs.module.scss
index 554bf40..8a891ea 100644
--- a/src/components/ui/tabs/tabs.module.scss
+++ b/src/components/ui/tabs/tabs.module.scss
@@ -1,5 +1,6 @@
.list {
display: inline-flex;
+ flex-shrink: 0;
background-color: var(--color-dark-900);
}
diff --git a/src/components/ui/text-field/text-field.module.scss b/src/components/ui/text-field/text-field.module.scss
index eaeb347..85e2d68 100644
--- a/src/components/ui/text-field/text-field.module.scss
+++ b/src/components/ui/text-field/text-field.module.scss
@@ -1,5 +1,6 @@
.root {
width: 100%;
+ min-width: 200px;
}
.fieldContainer {
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
new file mode 100644
index 0000000..25b14f1
--- /dev/null
+++ b/src/hooks/index.ts
@@ -0,0 +1 @@
+export * from './use-query-param'
diff --git a/src/hooks/use-query-param/index.ts b/src/hooks/use-query-param/index.ts
new file mode 100644
index 0000000..25b14f1
--- /dev/null
+++ b/src/hooks/use-query-param/index.ts
@@ -0,0 +1 @@
+export * from './use-query-param'
diff --git a/src/hooks/use-query-param/use-query-param.ts b/src/hooks/use-query-param/use-query-param.ts
new file mode 100644
index 0000000..83591e0
--- /dev/null
+++ b/src/hooks/use-query-param/use-query-param.ts
@@ -0,0 +1,36 @@
+import { isNil } from 'remeda'
+
+export function useQueryParam(
+ searchParams: URLSearchParams,
+ setSearchParams: (searchParams: URLSearchParams) => void,
+ param: string,
+ defaultValue?: T
+): [T | null, (value: T | null) => void] {
+ const paramValue = searchParams.get(param)
+ const convertedValue = getConvertedValue(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(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
+}
diff --git a/src/main.tsx b/src/main.tsx
index c4965f9..f27e98f 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,11 +1,12 @@
import { StrictMode } from 'react'
-import { App } from './App'
import { createRoot } from 'react-dom/client'
+import './styles/index.scss'
import '@fontsource/roboto/400.css'
import '@fontsource/roboto/700.css'
-import './styles/index.scss'
+
+import { App } from './App'
createRoot(document.getElementById('root')!).render(
diff --git a/src/pages/decks-page/decks-page.module.scss b/src/pages/decks-page/decks-page.module.scss
index ca89803..9bf0cb6 100644
--- a/src/pages/decks-page/decks-page.module.scss
+++ b/src/pages/decks-page/decks-page.module.scss
@@ -13,5 +13,9 @@
display: flex;
grid-template-columns: repeat(4, 1fr);
column-gap: 16px;
- margin-bottom: 16px;
+ margin: 10px 0 16px;
+}
+
+.pagination {
+ margin-top: 24px;
}
diff --git a/src/pages/decks-page/decks-page.tsx b/src/pages/decks-page/decks-page.tsx
index 1da4d00..d8090cb 100644
--- a/src/pages/decks-page/decks-page.tsx
+++ b/src/pages/decks-page/decks-page.tsx
@@ -1,69 +1,65 @@
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 { DeleteDeckDialog } from '@/components/decks/delete-deck-dialog'
import { Pagination } from '@/components/ui/pagination'
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 {
- Tab,
useCreateDeckMutation,
useDeleteDeckMutation,
useGetDecksQuery,
useUpdateDeckMutation,
} 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'
export const DecksPage = () => {
+ const { data: me } = useMeQuery()
const [showCreateModal, setShowCreateModal] = useState(false)
const [deckToDeleteId, setDeckToDeleteId] = useState(null)
const [deckToEditId, setDeckToEditId] = useState(null)
const showEditModal = !!deckToEditId
- const dispatch = useAppDispatch()
- const currentPage = useAppSelector(selectDecksCurrentPage)
- const minCards = useAppSelector(selectDecksMinCards)
- const maxCards = useAppSelector(selectDecksMaxCards)
- const currentTab = useAppSelector(selectDecksCurrentTab)
- const search = useAppSelector(selectDecksSearch)
- const setCurrentPage = (page: number) => dispatch(decksSlice.actions.setCurrentPage(page))
- const setMinCards = (minCards: number) => dispatch(decksSlice.actions.setMinCards(minCards))
- const setMaxCards = (maxCards: number) => dispatch(decksSlice.actions.setMaxCards(maxCards))
- const setSearch = (search: string) => dispatch(decksSlice.actions.setSearch(search))
- const setCurrentTab = (tab: Tab) => dispatch(decksSlice.actions.setCurrentTab(tab))
+ const {
+ currentPage,
+ currentTab,
+ maxCardsCount,
+ minCardsCount,
+ rangeValue,
+ search,
+ setCurrentPage,
+ setCurrentTab,
+ setMaxCards,
+ setMinCards,
+ setRangeValue,
+ setSearch,
+ setSort,
+ sort,
+ } = useDeckSearchParams()
- const resetFilters = () => {
- 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 currentUserId = me?.id
const authorId = currentTab === 'my' ? currentUserId : undefined
-
- const { data: decks } = useGetDecksQuery({
+ const { currentData: decksCurrentData, data: decksData } = useGetDecksQuery({
authorId,
currentPage,
- maxCardsCount: maxCards,
- minCardsCount: minCards,
+ maxCardsCount,
+ minCardsCount,
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 deckToDeleteName = decks?.items?.find(deck => deck.id === deckToDeleteId)?.name
@@ -73,10 +69,26 @@ export const DecksPage = () => {
const [createDeck] = useCreateDeckMutation()
const [deleteDeck] = useDeleteDeckMutation()
const [updateDeck] = useUpdateDeckMutation()
+
const openCreateModal = () => setShowCreateModal(true)
- if (!decks) {
- return loading...
+ const handleSearch = (search: null | string) => {
+ 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
}
return (
@@ -110,14 +122,22 @@ export const DecksPage = () => {
setShowCreateModal(false)}
- onConfirm={createDeck}
+ onConfirm={data => {
+ resetFilters()
+ createDeck(data)
+ }}
onOpenChange={setShowCreateModal}
open={showCreateModal}
/>
-
- setCurrentTab(value as Tab)} value={currentTab}>
+
+
My decks
All decks
@@ -135,15 +155,18 @@ export const DecksPage = () => {
diff --git a/src/pages/decks-page/use-deck-search-params.ts b/src/pages/decks-page/use-deck-search-params.ts
new file mode 100644
index 0000000..118fca3
--- /dev/null
+++ b/src/pages/decks-page/use-deck-search-params.ts
@@ -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
(
+ searchParams,
+ setSearchParams,
+ 'page',
+ 1
+ )
+ const [minCardsCount, setMinCards] = useQueryParam(
+ searchParams,
+ setSearchParams,
+ 'minCards',
+ 0
+ )
+ const [maxCardsCount, setMaxCards] = useQueryParam(
+ searchParams,
+ setSearchParams,
+ 'maxCards'
+ )
+ const [search, setSearch] = useQueryParam(searchParams, setSearchParams, 'search')
+ const [currentTab, setCurrentTab] = useQueryParam(
+ searchParams,
+ setSearchParams,
+ 'currentTab',
+ 'all'
+ )
+ const [rangeValue, setRangeValue] = useState([minCardsCount, maxCardsCount])
+ const [sortKey, setSortKey] = useQueryParam(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,
+ }
+}
diff --git a/src/pages/sign-in-page/sign-in-page.tsx b/src/pages/sign-in-page/sign-in-page.tsx
index 9124647..f1fc263 100644
--- a/src/pages/sign-in-page/sign-in-page.tsx
+++ b/src/pages/sign-in-page/sign-in-page.tsx
@@ -1,9 +1,26 @@
+import { useNavigate } from 'react-router-dom'
+import { toast } from 'react-toastify'
+
import { Page, SignIn } from '@/components'
+import { useLoginMutation } from '@/services/auth/auth.service'
+import { LoginArgs } from '@/services/auth/auth.types'
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 (
- {}} />
+
)
}
diff --git a/src/router.tsx b/src/router.tsx
index c46122e..18b6627 100644
--- a/src/router.tsx
+++ b/src/router.tsx
@@ -6,9 +6,11 @@ import {
createBrowserRouter,
} from 'react-router-dom'
-import { DecksPage, SignInPage } from './pages'
+import { Layout, useAuthContext } from '@/components/layout'
import { DeckPage } from '@/pages/deck-page/deck-page'
+import { DecksPage, SignInPage } from './pages'
+
const publicRoutes: RouteObject[] = [
{
children: [
@@ -34,10 +36,15 @@ const privateRoutes: RouteObject[] = [
const router = createBrowserRouter([
{
- children: privateRoutes,
- element: ,
+ children: [
+ {
+ children: privateRoutes,
+ element: ,
+ },
+ ...publicRoutes,
+ ],
+ element: ,
},
- ...publicRoutes,
])
export const Router = () => {
@@ -45,7 +52,7 @@ export const Router = () => {
}
function PrivateRoutes() {
- const isAuthenticated = true
+ const { isAuthenticated } = useAuthContext()
return isAuthenticated ? :
}
diff --git a/src/services/auth/auth.service.ts b/src/services/auth/auth.service.ts
new file mode 100644
index 0000000..da53d40
--- /dev/null
+++ b/src/services/auth/auth.service.ts
@@ -0,0 +1,21 @@
+import { baseApi } from '..'
+import { LoginArgs, User } from './auth.types'
+
+export const authService = baseApi.injectEndpoints({
+ endpoints: builder => ({
+ login: builder.mutation({
+ invalidatesTags: ['Me'],
+ query: body => ({
+ body,
+ method: 'POST',
+ url: '/v1/auth/login',
+ }),
+ }),
+ me: builder.query({
+ providesTags: ['Me'],
+ query: () => '/v1/auth/me',
+ }),
+ }),
+})
+
+export const { useLoginMutation, useMeQuery } = authService
diff --git a/src/services/auth/auth.types.ts b/src/services/auth/auth.types.ts
new file mode 100644
index 0000000..fa5be7f
--- /dev/null
+++ b/src/services/auth/auth.types.ts
@@ -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
+}
diff --git a/src/services/base-api.ts b/src/services/base-api.ts
index f2f9776..b78e007 100644
--- a/src/services/base-api.ts
+++ b/src/services/base-api.ts
@@ -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({
- baseQuery: fetchBaseQuery({
- baseUrl: 'https://api.flashcards.andrii.es',
- credentials: 'include',
- prepareHeaders: headers => {
- headers.append('x-auth-skip', 'true')
- },
- }),
+ baseQuery: baseQueryWithReauth,
endpoints: () => ({}),
reducerPath: 'baseApi',
- tagTypes: ['Decks'],
+ tagTypes: ['Decks', 'Me'],
})
diff --git a/src/services/base-query-with-reauth.ts b/src/services/base-query-with-reauth.ts
new file mode 100644
index 0000000..2fa97fb
--- /dev/null
+++ b/src/services/base-query-with-reauth.ts
@@ -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
+}
diff --git a/src/services/decks/decks.selectors.ts b/src/services/decks/decks.selectors.ts
index 7d1e844..800f154 100644
--- a/src/services/decks/decks.selectors.ts
+++ b/src/services/decks/decks.selectors.ts
@@ -1,4 +1,4 @@
-import { RootState } from '@/services/store.ts'
+import { RootState } from '@/services/store'
export const selectDecksCurrentPage = (state: RootState) => state.decks.currentPage
diff --git a/src/services/decks/decks.service.ts b/src/services/decks/decks.service.ts
index 63d0d0c..43184ec 100644
--- a/src/services/decks/decks.service.ts
+++ b/src/services/decks/decks.service.ts
@@ -7,11 +7,30 @@ import {
UpdateDeckArgs,
baseApi,
} from '@/services'
+import { RootState } from '@/services/store'
+import { getValuable } from '@/utils'
const decksService = baseApi.injectEndpoints({
endpoints: builder => ({
createDeck: builder.mutation({
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 => ({
body,
method: 'POST',
@@ -35,13 +54,49 @@ const decksService = baseApi.injectEndpoints({
providesTags: ['Decks'],
query: args => {
return {
- params: args ?? undefined,
+ params: args ? getValuable(args) : undefined,
url: `v1/decks`,
}
},
}),
updateDeck: builder.mutation({
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 }) => ({
body,
method: 'PATCH',
diff --git a/src/services/decks/decks.slice.ts b/src/services/decks/decks.slice.ts
index 0d1f8fb..5df028d 100644
--- a/src/services/decks/decks.slice.ts
+++ b/src/services/decks/decks.slice.ts
@@ -3,6 +3,7 @@ import { PayloadAction, createSlice } from '@reduxjs/toolkit'
export const decksSlice = createSlice({
initialState: {
+ authorId: undefined as string | undefined,
currentPage: 1,
currentTab: 'all' as Tab,
maxCards: undefined as number | undefined,
@@ -18,14 +19,17 @@ export const decksSlice = createSlice({
resetFilters: state => {
state.search = ''
state.currentTab = 'all'
+ state.authorId = undefined
state.minCards = 0
state.maxCards = undefined
+ state.currentPage = 1
},
setCurrentPage: (state, action: PayloadAction) => {
state.currentPage = action.payload
},
- setCurrentTab: (state, action: PayloadAction) => {
- state.currentTab = action.payload
+ setCurrentTab: (state, action: PayloadAction<{ authorId?: string; tab: Tab }>) => {
+ state.currentTab = action.payload.tab
+ state.authorId = action.payload.authorId
},
setMaxCards: (state, action: PayloadAction) => {
state.maxCards = action.payload
diff --git a/src/services/decks/decks.types.ts b/src/services/decks/decks.types.ts
index b8c005c..777c54c 100644
--- a/src/services/decks/decks.types.ts
+++ b/src/services/decks/decks.types.ts
@@ -54,13 +54,13 @@ export type Card = {
}
export type GetDecksArgs = {
- authorId?: string
- currentPage?: number
- itemsPerPage?: number
- maxCardsCount?: number
- minCardsCount?: number
- name?: string
- orderBy?: string
+ authorId?: null | string
+ currentPage?: null | number
+ itemsPerPage?: null | number
+ maxCardsCount?: null | number
+ minCardsCount?: null | number
+ name?: null | string
+ orderBy?: null | string
}
export type CreateDeckArgs = {
diff --git a/src/services/store.ts b/src/services/store.ts
index 29a6c32..7e3e2ef 100644
--- a/src/services/store.ts
+++ b/src/services/store.ts
@@ -1,10 +1,11 @@
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
-import { baseApi } from './base-api'
import { decksSlice } from '@/services/decks/decks.slice'
import { configureStore } from '@reduxjs/toolkit'
import { setupListeners } from '@reduxjs/toolkit/query/react'
+import { baseApi } from './base-api'
+
export const store = configureStore({
middleware: getDefaultMiddleware => getDefaultMiddleware().concat(baseApi.middleware),
reducer: {
diff --git a/src/styles/_boilerplate.scss b/src/styles/_boilerplate.scss
index 098c1d2..9e53f87 100644
--- a/src/styles/_boilerplate.scss
+++ b/src/styles/_boilerplate.scss
@@ -41,6 +41,8 @@ select,
textarea,
optgroup,
option {
+ box-sizing: border-box;
+
font-family: inherit;
font-size: inherit;
font-weight: inherit;
diff --git a/src/utils/get-valuable.ts b/src/utils/get-valuable.ts
new file mode 100644
index 0000000..a41f3dd
--- /dev/null
+++ b/src/utils/get-valuable.ts
@@ -0,0 +1,9 @@
+type Valuable = { [K in keyof T as T[K] extends null | undefined ? never : K]: T[K] }
+
+export function getValuable>(obj: T): V {
+ return Object.fromEntries(
+ Object.entries(obj).filter(
+ ([, v]) => !((typeof v === 'string' && !v.length) || v === null || typeof v === 'undefined')
+ )
+ ) as V
+}
diff --git a/src/utils/index.ts b/src/utils/index.ts
index edf1e3c..2d200ba 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -1 +1,2 @@
export * from './date'
+export * from './get-valuable'
diff --git a/tsconfig.json b/tsconfig.json
index 1c8358f..b151dc6 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -8,7 +8,7 @@
/* Bundler mode */
"moduleResolution": "bundler",
- "allowImportingTsExtensions": true,
+ "allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,