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