diff --git a/package.json b/package.json
index 6aa27f2..870b6cc 100644
--- a/package.json
+++ b/package.json
@@ -18,11 +18,13 @@
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-radio-group": "^1.1.3",
+ "@reduxjs/toolkit": "^1.9.5",
"@storybook/theming": "^7.2.1",
"clsx": "^2.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.45.2",
+ "react-redux": "^8.1.2",
"react-router-dom": "^6.14.2",
"react-toastify": "^9.1.3",
"remeda": "^1.24.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6e03f24..5b09e77 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -20,6 +20,9 @@ dependencies:
'@radix-ui/react-radio-group':
specifier: ^1.1.3
version: 1.1.3(@types/react-dom@18.2.7)(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0)
+ '@reduxjs/toolkit':
+ specifier: ^1.9.5
+ version: 1.9.5(react-redux@8.1.2)(react@18.2.0)
'@storybook/theming':
specifier: ^7.2.1
version: 7.2.1(react-dom@18.2.0)(react@18.2.0)
@@ -35,6 +38,9 @@ dependencies:
react-hook-form:
specifier: ^7.45.2
version: 7.45.2(react@18.2.0)
+ react-redux:
+ specifier: ^8.1.2
+ version: 8.1.2(@types/react-dom@18.2.7)(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0)(redux@4.2.1)
react-router-dom:
specifier: ^6.14.2
version: 6.14.2(react-dom@18.2.0)(react@18.2.0)
@@ -2672,6 +2678,25 @@ packages:
dependencies:
'@babel/runtime': 7.22.6
+ /@reduxjs/toolkit@1.9.5(react-redux@8.1.2)(react@18.2.0):
+ resolution: {integrity: sha512-Rt97jHmfTeaxL4swLRNPD/zV4OxTes4la07Xc4hetpUW/vc75t5m1ANyxG6ymnEQ2FsLQsoMlYB2vV1sO3m8tQ==}
+ peerDependencies:
+ react: ^16.9.0 || ^17.0.0 || ^18
+ react-redux: ^7.2.1 || ^8.0.2
+ peerDependenciesMeta:
+ react:
+ optional: true
+ react-redux:
+ optional: true
+ dependencies:
+ immer: 9.0.21
+ react: 18.2.0
+ react-redux: 8.1.2(@types/react-dom@18.2.7)(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0)(redux@4.2.1)
+ redux: 4.2.1
+ redux-thunk: 2.4.2(redux@4.2.1)
+ reselect: 4.1.8
+ dev: false
+
/@remix-run/router@1.7.2:
resolution: {integrity: sha512-7Lcn7IqGMV+vizMPoEl5F0XDshcdDYtMI6uJLQdQz5CfZAwy3vvGKYSUk789qndt5dEC4HfSjviSYlSoHGL2+A==}
engines: {node: '>=14'}
@@ -3951,6 +3976,13 @@ packages:
'@types/node': 20.4.5
dev: true
+ /@types/hoist-non-react-statics@3.3.1:
+ resolution: {integrity: sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==}
+ dependencies:
+ '@types/react': 18.2.15
+ hoist-non-react-statics: 3.3.2
+ dev: false
+
/@types/http-errors@2.0.1:
resolution: {integrity: sha512-/K3ds8TRAfBvi5vfjuz8y6+GiAYBZ0x4tXv1Av6CWBWn0IlADc+ZX9pMq7oU0fNQPnBwIZl3rmeLp6SBApbxSQ==}
@@ -4073,6 +4105,10 @@ packages:
resolution: {integrity: sha512-cputDpIbFgLUaGQn6Vqg3/YsJwxUwHLO13v3i5ouxT4lat0khip9AEWxtERujXV9wxIB1EyF97BSJFt6vpdI8g==}
dev: true
+ /@types/use-sync-external-store@0.0.3:
+ resolution: {integrity: sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==}
+ dev: false
+
/@types/yargs-parser@21.0.0:
resolution: {integrity: sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==}
dev: true
@@ -6647,7 +6683,6 @@ packages:
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
dependencies:
react-is: 16.13.1
- dev: true
/hosted-git-info@2.8.9:
resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==}
@@ -6726,6 +6761,10 @@ packages:
engines: {node: '>= 4'}
dev: true
+ /immer@9.0.21:
+ resolution: {integrity: sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==}
+ dev: false
+
/immutable@4.3.1:
resolution: {integrity: sha512-lj9cnmB/kVS0QHsJnYKD1uo3o39nrbKxszjnqS9Fr6NB7bZzW45U6WSGBPKXDL/CvDKqDNPA4r3DoDQ8GTxo2A==}
dev: true
@@ -8358,7 +8397,6 @@ packages:
/react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
- dev: true
/react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
@@ -8366,7 +8404,40 @@ packages:
/react-is@18.1.0:
resolution: {integrity: sha512-Fl7FuabXsJnV5Q1qIOQwx/sagGF18kogb4gpfcG4gjLBWO0WDiiz1ko/ExayuxE7InyQkBLkxRFG5oxY6Uu3Kg==}
- dev: true
+
+ /react-redux@8.1.2(@types/react-dom@18.2.7)(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0)(redux@4.2.1):
+ resolution: {integrity: sha512-xJKYI189VwfsFc4CJvHqHlDrzyFTY/3vZACbE+rr/zQ34Xx1wQfB4OTOSeOSNrF6BDVe8OOdxIrAnMGXA3ggfw==}
+ peerDependencies:
+ '@types/react': ^16.8 || ^17.0 || ^18.0
+ '@types/react-dom': ^16.8 || ^17.0 || ^18.0
+ react: ^16.8 || ^17.0 || ^18.0
+ react-dom: ^16.8 || ^17.0 || ^18.0
+ react-native: '>=0.59'
+ redux: ^4 || ^5.0.0-beta.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+ react-dom:
+ optional: true
+ react-native:
+ optional: true
+ redux:
+ optional: true
+ dependencies:
+ '@babel/runtime': 7.22.6
+ '@types/hoist-non-react-statics': 3.3.1
+ '@types/react': 18.2.15
+ '@types/react-dom': 18.2.7
+ '@types/use-sync-external-store': 0.0.3
+ hoist-non-react-statics: 3.3.2
+ react: 18.2.0
+ react-dom: 18.2.0(react@18.2.0)
+ react-is: 18.1.0
+ redux: 4.2.1
+ use-sync-external-store: 1.2.0(react@18.2.0)
+ dev: false
/react-refresh@0.14.0:
resolution: {integrity: sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==}
@@ -8565,6 +8636,20 @@ packages:
strip-indent: 4.0.0
dev: true
+ /redux-thunk@2.4.2(redux@4.2.1):
+ resolution: {integrity: sha512-+P3TjtnP0k/FEjcBL5FZpoovtvrTNT/UXd4/sluaSyrURlSlhLSzEdfsTBW7WsKB6yPvgd7q/iZPICFjW4o57Q==}
+ peerDependencies:
+ redux: ^4
+ dependencies:
+ redux: 4.2.1
+ dev: false
+
+ /redux@4.2.1:
+ resolution: {integrity: sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==}
+ dependencies:
+ '@babel/runtime': 7.22.6
+ dev: false
+
/regenerate-unicode-properties@10.1.0:
resolution: {integrity: sha512-d1VudCLoIGitcU/hEg2QqvyGZQmdC0Lf8BqdOMXGFSvJP4bNV1+XqbPQeHHLD51Jh4QJJ225dlIFvY4Ly6MXmQ==}
engines: {node: '>=4'}
@@ -8649,6 +8734,10 @@ packages:
engines: {node: '>=0.10.5'}
dev: true
+ /reselect@4.1.8:
+ resolution: {integrity: sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==}
+ dev: false
+
/resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -9814,6 +9903,14 @@ packages:
react: 18.2.0
tslib: 2.6.1
+ /use-sync-external-store@1.2.0(react@18.2.0):
+ resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0
+ dependencies:
+ react: 18.2.0
+ dev: false
+
/util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
diff --git a/src/App.tsx b/src/App.tsx
index 5541660..6f08196 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,3 +1,12 @@
+import { Provider } from 'react-redux'
+
+import { Router } from '@/router'
+import { store } from '@/services/store'
+
export function App() {
- return
Hello
+ return (
+
+
+
+ )
}
diff --git a/src/components/auth/recover-password/recover-password.tsx b/src/components/auth/recover-password/recover-password.tsx
index 0e9128b..75e5ff1 100644
--- a/src/components/auth/recover-password/recover-password.tsx
+++ b/src/components/auth/recover-password/recover-password.tsx
@@ -8,9 +8,9 @@ import { Button, Card, ControlledTextField, Typography } from '../../ui'
import s from './recover-password.module.scss'
-const schema = z.object({
- email: z.string().email('Invalid email address').nonempty('Enter email'),
-})
+import { emailSchema } from '@/components'
+
+const schema = emailSchema
type FormType = z.infer
diff --git a/src/components/auth/sign-in/sign-in.tsx b/src/components/auth/sign-in/sign-in.tsx
index 3089c00..e9ec86b 100644
--- a/src/components/auth/sign-in/sign-in.tsx
+++ b/src/components/auth/sign-in/sign-in.tsx
@@ -8,12 +8,17 @@ import { Button, Card, ControlledCheckbox, ControlledTextField, Typography } fro
import s from './sign-in.module.scss'
-const schema = z.object({
+export const emailSchema = z.object({
email: z.string().email('Invalid email address').nonempty('Enter email'),
- password: z.string().nonempty('Enter password'),
- rememberMe: z.boolean().optional(),
})
+const schema = z
+ .object({
+ password: z.string().nonempty('Enter password'),
+ rememberMe: z.boolean().optional(),
+ })
+ .merge(emailSchema)
+
type FormType = z.infer
type Props = {
diff --git a/src/components/auth/sign-up/sign-up.tsx b/src/components/auth/sign-up/sign-up.tsx
index e6359f7..121c96f 100644
--- a/src/components/auth/sign-up/sign-up.tsx
+++ b/src/components/auth/sign-up/sign-up.tsx
@@ -9,12 +9,14 @@ import { Button, Card, ControlledTextField, Typography } from '../../ui'
import s from './sign-up.module.scss'
+import { emailSchema } from '@/components'
+
const schema = z
.object({
- email: z.string().email('Invalid email address').nonempty('Enter email'),
password: z.string().nonempty('Enter password'),
passwordConfirmation: z.string().nonempty('Confirm your password'),
})
+ .merge(emailSchema)
.superRefine((data, ctx) => {
if (data.password !== data.passwordConfirmation) {
ctx.addIssue({
diff --git a/src/pages/decks/decks.tsx b/src/pages/decks/decks.tsx
new file mode 100644
index 0000000..74e2ab0
--- /dev/null
+++ b/src/pages/decks/decks.tsx
@@ -0,0 +1,96 @@
+import { useState } from 'react'
+
+import { Button, TextField } from '@/components'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeadCell,
+ TableRow,
+} from '@/components/ui/table'
+import { useCreateDeckMutation, useGetDecksQuery } from '@/services/decks'
+import { decksSlice } from '@/services/decks/decks.slice.ts'
+import { useAppDispatch, useAppSelector } from '@/services/store.ts'
+
+export const Decks = () => {
+ const [cardName, setCardName] = useState('')
+
+ const dispatch = useAppDispatch()
+ const itemsPerPage = useAppSelector(state => state.decksSlice.itemsPerPage)
+ const currentPage = useAppSelector(state => state.decksSlice.currentPage)
+ const searchByName = useAppSelector(state => state.decksSlice.searchByName)
+
+ const setItemsPerPage = (itemsPerPage: number) =>
+ dispatch(decksSlice.actions.setItemsPerPage(itemsPerPage))
+ const setCurrentPage = (currentPage: number) =>
+ dispatch(decksSlice.actions.setCurrentPage(currentPage))
+ const setSearch = (search: string) => dispatch(decksSlice.actions.setSearchByName(search))
+
+ const { isLoading, data, refetch } = useGetDecksQuery({
+ itemsPerPage,
+ currentPage,
+ name: searchByName,
+ orderBy: 'created-desc',
+ })
+
+ const [createDeck, { isLoading: isCreateDeckLoading }] = useCreateDeckMutation()
+
+ const handleCreateClicked = () => createDeck({ name: cardName })
+
+ if (isLoading) return Loading...
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
setSearch(e.currentTarget.value)} />
+ setCardName(e.currentTarget.value)}
+ label={'card name'}
+ />
+
+ isCreateDeckLoading: {isCreateDeckLoading.toString()}
+
+ {/* table*/}
+
+ {/* thead*/}
+
+ {/* tr*/}
+ Name
+ {/* th*/}
+ Cards
+ Last Updated
+ Created By
+
+
+
+ {/* tbody*/}
+ {data?.items.map(deck => {
+ return (
+
+ {/* tr*/}
+ {deck.name} {/* td*/}
+ {deck.cardsCount}
+ {new Date(deck.updated).toLocaleString('en-GB')}
+ {deck.author.name}
+
+ )
+ })}
+
+
+
+ )
+}
diff --git a/src/router.tsx b/src/router.tsx
new file mode 100644
index 0000000..abed2b9
--- /dev/null
+++ b/src/router.tsx
@@ -0,0 +1,41 @@
+import {
+ createBrowserRouter,
+ Navigate,
+ Outlet,
+ RouteObject,
+ RouterProvider,
+} from 'react-router-dom'
+
+import { Decks } from '@/pages/decks/decks.tsx'
+
+const publicRoutes: RouteObject[] = [
+ {
+ path: '/login',
+ element: login
,
+ },
+]
+
+const privateRoutes: RouteObject[] = [
+ {
+ path: '/',
+ element: ,
+ },
+]
+
+const router = createBrowserRouter([
+ {
+ element: ,
+ children: privateRoutes,
+ },
+ ...publicRoutes,
+])
+
+export const Router = () => {
+ return
+}
+
+function PrivateRoutes() {
+ const isAuthenticated = true
+
+ return isAuthenticated ? :
+}
diff --git a/src/services/base-api.ts b/src/services/base-api.ts
new file mode 100644
index 0000000..43a17da
--- /dev/null
+++ b/src/services/base-api.ts
@@ -0,0 +1,14 @@
+import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
+
+export const baseApi = createApi({
+ reducerPath: 'baseApi',
+ tagTypes: ['Decks'],
+ baseQuery: fetchBaseQuery({
+ baseUrl: 'https://api.flashcards.andrii.es',
+ credentials: 'include',
+ prepareHeaders: headers => {
+ headers.append('x-auth-skip', 'true')
+ },
+ }),
+ endpoints: () => ({}),
+})
diff --git a/src/services/decks/decks.slice.ts b/src/services/decks/decks.slice.ts
new file mode 100644
index 0000000..b06ad48
--- /dev/null
+++ b/src/services/decks/decks.slice.ts
@@ -0,0 +1,23 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit'
+
+const initialState = {
+ itemsPerPage: 10,
+ currentPage: 1,
+ searchByName: '',
+}
+
+export const decksSlice = createSlice({
+ initialState,
+ name: 'decksSlice',
+ reducers: {
+ setItemsPerPage: (state, action: PayloadAction) => {
+ state.itemsPerPage = action.payload
+ },
+ setCurrentPage: (state, action: PayloadAction) => {
+ state.currentPage = action.payload
+ },
+ setSearchByName: (state, action: PayloadAction) => {
+ state.searchByName = action.payload
+ },
+ },
+})
diff --git a/src/services/decks/decks.ts b/src/services/decks/decks.ts
new file mode 100644
index 0000000..1d30c2f
--- /dev/null
+++ b/src/services/decks/decks.ts
@@ -0,0 +1,31 @@
+import { baseApi } from '@/services/base-api.ts'
+import { CreateDeckArgs, Deck, DecksResponse, GetDecksArgs } from '@/services/decks/types.ts'
+
+const decksApi = baseApi.injectEndpoints({
+ endpoints: builder => {
+ return {
+ getDecks: builder.query({
+ query: args => {
+ return {
+ url: `v1/decks`,
+ method: 'GET',
+ params: args,
+ }
+ },
+ providesTags: ['Decks'],
+ }),
+ createDeck: builder.mutation({
+ query: ({ name }) => {
+ return {
+ url: 'v1/decks',
+ method: 'POST',
+ body: { name },
+ }
+ },
+ invalidatesTags: ['Decks'],
+ }),
+ }
+ },
+})
+
+export const { useGetDecksQuery, useCreateDeckMutation } = decksApi
diff --git a/src/services/decks/index.ts b/src/services/decks/index.ts
new file mode 100644
index 0000000..7979ef0
--- /dev/null
+++ b/src/services/decks/index.ts
@@ -0,0 +1 @@
+export * from './decks'
diff --git a/src/services/decks/types.ts b/src/services/decks/types.ts
new file mode 100644
index 0000000..8b2cffd
--- /dev/null
+++ b/src/services/decks/types.ts
@@ -0,0 +1,37 @@
+import { PaginatedEntity, PaginatedRequest } from '@/services/types'
+
+export type GetDecksArgs = PaginatedRequest<{
+ minCardsCount?: number
+ maxCardsCount?: number
+ name?: string
+ authorId?: Author['id']
+ orderBy?: string
+}>
+
+export type CreateDeckArgs = {
+ name: string
+}
+export interface Author {
+ id: string
+ name: string
+}
+
+export interface Deck {
+ id: string
+ userId: string
+ name: string
+ isPrivate: boolean
+ shots: number
+ cover: string | null
+ rating: number
+ isDeleted: boolean | null
+ isBlocked: boolean | null
+ created: string
+ updated: string
+ cardsCount: number
+ author: Author
+}
+
+export type DecksResponse = PaginatedEntity & {
+ maxCardsCount: number
+}
diff --git a/src/services/store.ts b/src/services/store.ts
new file mode 100644
index 0000000..a367715
--- /dev/null
+++ b/src/services/store.ts
@@ -0,0 +1,18 @@
+import { configureStore } from '@reduxjs/toolkit'
+import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
+
+import { baseApi } from '@/services/base-api'
+import { decksSlice } from '@/services/decks/decks.slice.ts'
+
+export const store = configureStore({
+ reducer: {
+ [baseApi.reducerPath]: baseApi.reducer,
+ [decksSlice.name]: decksSlice.reducer,
+ },
+ middleware: getDefaultMiddleware => getDefaultMiddleware().concat(baseApi.middleware),
+})
+
+export type AppDispatch = typeof store.dispatch
+export type RootState = ReturnType
+export const useAppDispatch: () => AppDispatch = useDispatch
+export const useAppSelector: TypedUseSelectorHook = useSelector
diff --git a/src/services/types.ts b/src/services/types.ts
new file mode 100644
index 0000000..2f79f8a
--- /dev/null
+++ b/src/services/types.ts
@@ -0,0 +1,12 @@
+export type PaginatedEntity = {
+ pagination: Pagination
+ items: T[]
+}
+export interface Pagination {
+ totalPages: number
+ currentPage: number
+ itemsPerPage: number
+ totalItems: number
+}
+
+export type PaginatedRequest = Partial> & T