mirror of
https://github.com/ershisan99/flashcards-example-project.git
synced 2025-12-16 20:59:27 +00:00
lesson 4 finished
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
"@radix-ui/react-radio-group": "^1.1.3",
|
||||
"@reduxjs/toolkit": "^1.9.5",
|
||||
"@storybook/theming": "^7.2.1",
|
||||
"async-mutex": "^0.4.0",
|
||||
"clsx": "^2.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
|
||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -26,6 +26,9 @@ dependencies:
|
||||
'@storybook/theming':
|
||||
specifier: ^7.2.1
|
||||
version: 7.2.1(react-dom@18.2.0)(react@18.2.0)
|
||||
async-mutex:
|
||||
specifier: ^0.4.0
|
||||
version: 0.4.0
|
||||
clsx:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
@@ -4646,6 +4649,12 @@ packages:
|
||||
resolution: {integrity: sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==}
|
||||
dev: true
|
||||
|
||||
/async-mutex@0.4.0:
|
||||
resolution: {integrity: sha512-eJFZ1YhRR8UN8eBLoNzcDPcy/jqjsg6I1AP+KvWQX80BqOSW1oJPJXDylPUEeMr2ZQvHgnQ//Lp6f3RQ1zI7HA==}
|
||||
dependencies:
|
||||
tslib: 2.6.1
|
||||
dev: false
|
||||
|
||||
/async@3.2.4:
|
||||
resolution: {integrity: sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==}
|
||||
dev: true
|
||||
|
||||
@@ -8,9 +8,9 @@ import { Button, Card, ControlledTextField, Typography } from '../../ui'
|
||||
|
||||
import s from './recover-password.module.scss'
|
||||
|
||||
import { emailSchema } from '@/components'
|
||||
|
||||
const schema = emailSchema
|
||||
const schema = z.object({
|
||||
email: z.string().email('Invalid email address').nonempty('Enter email'),
|
||||
})
|
||||
|
||||
type FormType = z.infer<typeof schema>
|
||||
|
||||
|
||||
@@ -8,21 +8,17 @@ import { Button, Card, ControlledCheckbox, ControlledTextField, Typography } fro
|
||||
|
||||
import s from './sign-in.module.scss'
|
||||
|
||||
export const emailSchema = z.object({
|
||||
email: z.string().email('Invalid email address').nonempty('Enter email'),
|
||||
})
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
const schema = z.object({
|
||||
password: z.string().nonempty('Enter password'),
|
||||
email: z.string().email('Invalid email address').nonempty('Enter email'),
|
||||
rememberMe: z.boolean().optional(),
|
||||
})
|
||||
.merge(emailSchema)
|
||||
|
||||
type FormType = z.infer<typeof schema>
|
||||
|
||||
type Props = {
|
||||
onSubmit: (data: FormType) => void
|
||||
isSubmitting?: boolean
|
||||
}
|
||||
|
||||
export const SignIn = (props: Props) => {
|
||||
@@ -76,7 +72,7 @@ export const SignIn = (props: Props) => {
|
||||
>
|
||||
Forgot Password?
|
||||
</Typography>
|
||||
<Button className={s.button} fullWidth type={'submit'}>
|
||||
<Button className={s.button} fullWidth type={'submit'} disabled={props.isSubmitting}>
|
||||
Sign In
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -9,14 +9,12 @@ import { Button, Card, ControlledTextField, Typography } from '../../ui'
|
||||
|
||||
import s from './sign-up.module.scss'
|
||||
|
||||
import { emailSchema } from '@/components'
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
password: z.string().nonempty('Enter password'),
|
||||
passwordConfirmation: z.string().nonempty('Confirm your password'),
|
||||
email: z.string().email('Invalid email address').nonempty('Enter email'),
|
||||
})
|
||||
.merge(emailSchema)
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.password !== data.passwordConfirmation) {
|
||||
ctx.addIssue({
|
||||
|
||||
@@ -198,8 +198,6 @@ export const WithSort = {
|
||||
return `${sort.key}-${sort.direction}`
|
||||
}, [sort])
|
||||
|
||||
console.log(sortedString)
|
||||
|
||||
return (
|
||||
<table>
|
||||
<TableHeader columns={columns} sort={sort} onSort={setSort} />
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Button, TextField } from '@/components'
|
||||
import { useForm } from 'react-hook-form'
|
||||
|
||||
import { Button, ControlledTextField, TextField } from '@/components'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -9,59 +11,82 @@ import {
|
||||
TableHeadCell,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { useCreateDeckMutation, useGetDecksQuery } from '@/services/decks'
|
||||
import { useLogoutMutation } from '@/services/auth/auth.ts'
|
||||
import { useCreateDeckMutation, useDeleteDeckMutation, useGetDecksQuery } from '@/services/decks'
|
||||
import { decksSlice } from '@/services/decks/decks.slice.ts'
|
||||
import { CreateDeckArgs } from '@/services/decks/types.ts'
|
||||
import { useAppDispatch, useAppSelector } from '@/services/store.ts'
|
||||
|
||||
export const Decks = () => {
|
||||
const [cardName, setCardName] = useState('')
|
||||
|
||||
const { register, control, handleSubmit } = useForm<{
|
||||
name: string
|
||||
cover: File[]
|
||||
}>()
|
||||
|
||||
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 orderBy = useAppSelector(state => state.decksSlice.orderBy)
|
||||
|
||||
const setItemsPerPage = (itemsPerPage: number) =>
|
||||
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({
|
||||
const {
|
||||
isLoading,
|
||||
currentData: data,
|
||||
refetch,
|
||||
} = useGetDecksQuery({
|
||||
itemsPerPage,
|
||||
currentPage,
|
||||
name: searchByName,
|
||||
orderBy: 'created-desc',
|
||||
orderBy,
|
||||
})
|
||||
|
||||
const [createDeck, { isLoading: isCreateDeckLoading }] = useCreateDeckMutation()
|
||||
const [deleteDeck, { error }] = useDeleteDeckMutation()
|
||||
const [logout] = useLogoutMutation()
|
||||
|
||||
const handleCreateClicked = () => createDeck({ name: cardName })
|
||||
const handleCreateClicked = handleSubmit(data => {
|
||||
const formData = new FormData()
|
||||
|
||||
console.log(data.cover)
|
||||
formData.append('name', data.name)
|
||||
formData.append('cover', data.cover[0])
|
||||
|
||||
createDeck(formData as unknown as CreateDeckArgs)
|
||||
})
|
||||
|
||||
if (isLoading) return <div>Loading...</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<Button onClick={refetch}>refetch</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button onClick={() => setItemsPerPage(10)}>itemsPerPage: 10</Button>
|
||||
<Button onClick={() => setItemsPerPage(20)}>itemsPerPage: 20</Button>
|
||||
<Button onClick={() => setItemsPerPage(30)}>itemsPerPage: 30</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button onClick={() => setCurrentPage(1)}>currentPage: 1</Button>
|
||||
<Button onClick={() => setCurrentPage(2)}>currentPage: 2</Button>
|
||||
<Button onClick={() => setCurrentPage(3)}>currentPage: 3</Button>
|
||||
</div>
|
||||
<TextField value={searchByName} onChange={e => setSearch(e.currentTarget.value)} />
|
||||
<TextField
|
||||
value={cardName}
|
||||
onChange={e => setCardName(e.currentTarget.value)}
|
||||
label={'card name'}
|
||||
/>
|
||||
<Button onClick={handleCreateClicked}>Create deck</Button>
|
||||
{/*<div>*/}
|
||||
{/* <Button onClick={refetch}>refetch</Button>*/}
|
||||
{/* <Button onClick={logout}>Logout</Button>*/}
|
||||
{/*</div>*/}
|
||||
{/*<div>*/}
|
||||
{/* <Button onClick={() => setItemsPerPage(10)}>itemsPerPage: 10</Button>*/}
|
||||
{/* <Button onClick={() => setItemsPerPage(20)}>itemsPerPage: 20</Button>*/}
|
||||
{/* <Button onClick={() => setItemsPerPage(30)}>itemsPerPage: 30</Button>*/}
|
||||
{/*</div>*/}
|
||||
{/*<div>*/}
|
||||
{/* <Button onClick={() => setCurrentPage(1)}>currentPage: 1</Button>*/}
|
||||
{/* <Button onClick={() => setCurrentPage(2)}>currentPage: 2</Button>*/}
|
||||
{/* <Button onClick={() => setCurrentPage(3)}>currentPage: 3</Button>*/}
|
||||
{/*</div>*/}
|
||||
{/*<TextField value={searchByName} onChange={e => setSearch(e.currentTarget.value)} />*/}
|
||||
<form onSubmit={handleCreateClicked}>
|
||||
<ControlledTextField name={'name'} control={control} label={'name'} />
|
||||
<input type={'file'} {...register('cover')} />
|
||||
<Button>Create deck</Button>
|
||||
</form>
|
||||
isCreateDeckLoading: {isCreateDeckLoading.toString()}
|
||||
<Table>
|
||||
{/* table*/}
|
||||
@@ -81,11 +106,13 @@ export const Decks = () => {
|
||||
{data?.items.map(deck => {
|
||||
return (
|
||||
<TableRow key={deck.id}>
|
||||
{/* tr*/}
|
||||
<TableCell>{deck.name}</TableCell> {/* td*/}
|
||||
<TableCell>{deck.cardsCount}</TableCell>
|
||||
<TableCell>{new Date(deck.updated).toLocaleString('en-GB')}</TableCell>
|
||||
<TableCell>{deck.author.name}</TableCell>
|
||||
<TableCell>
|
||||
<Button onClick={() => deleteDeck({ id: deck.id })}>Delete</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
|
||||
23
src/pages/sign-in/sign-in.tsx
Normal file
23
src/pages/sign-in/sign-in.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Navigate, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { SignIn } from '@/components'
|
||||
import { useLoginMutation, useMeQuery } from '@/services/auth/auth.ts'
|
||||
|
||||
export const SignInPage = () => {
|
||||
const { data, isLoading } = useMeQuery()
|
||||
const [signIn, { isLoading: isSigningIn }] = useLoginMutation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
if (isLoading) return <div>Loading...</div>
|
||||
if (data) return <Navigate to="/" />
|
||||
|
||||
const handleSignIn = (data: any) => {
|
||||
signIn(data)
|
||||
.unwrap()
|
||||
.then(() => {
|
||||
navigate('/')
|
||||
})
|
||||
}
|
||||
|
||||
return <SignIn onSubmit={handleSignIn} isSubmitting={isSigningIn} />
|
||||
}
|
||||
@@ -7,11 +7,13 @@ import {
|
||||
} from 'react-router-dom'
|
||||
|
||||
import { Decks } from '@/pages/decks/decks.tsx'
|
||||
import { SignInPage } from '@/pages/sign-in/sign-in.tsx'
|
||||
import { useMeQuery } from '@/services/auth/auth.ts'
|
||||
|
||||
const publicRoutes: RouteObject[] = [
|
||||
{
|
||||
path: '/login',
|
||||
element: <div>login</div>,
|
||||
element: <SignInPage />,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -35,7 +37,11 @@ export const Router = () => {
|
||||
}
|
||||
|
||||
function PrivateRoutes() {
|
||||
const isAuthenticated = true
|
||||
const { data, isLoading } = useMeQuery()
|
||||
|
||||
if (isLoading) return <div>Loading...</div>
|
||||
|
||||
const isAuthenticated = !!data
|
||||
|
||||
return isAuthenticated ? <Outlet /> : <Navigate to="/login" />
|
||||
}
|
||||
|
||||
60
src/services/auth/auth.ts
Normal file
60
src/services/auth/auth.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { baseApi } from '@/services/base-api.ts'
|
||||
|
||||
const authApi = baseApi.injectEndpoints({
|
||||
endpoints: builder => {
|
||||
return {
|
||||
me: builder.query<any, void>({
|
||||
query: () => {
|
||||
return {
|
||||
url: `v1/auth/me`,
|
||||
method: 'GET',
|
||||
}
|
||||
},
|
||||
extraOptions: {
|
||||
maxRetries: 0,
|
||||
},
|
||||
providesTags: ['Me'],
|
||||
}),
|
||||
login: builder.mutation<any, any>({
|
||||
query: args => {
|
||||
return {
|
||||
url: `v1/auth/login`,
|
||||
method: 'POST',
|
||||
params: args,
|
||||
}
|
||||
},
|
||||
invalidatesTags: ['Me'],
|
||||
}),
|
||||
logout: builder.mutation({
|
||||
query: () => {
|
||||
return {
|
||||
url: `v1/auth/logout`,
|
||||
method: 'POST',
|
||||
}
|
||||
},
|
||||
async onQueryStarted(_, { dispatch, queryFulfilled }) {
|
||||
const patchResult = dispatch(
|
||||
authApi.util.updateQueryData('me', undefined, () => {
|
||||
return null
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
await queryFulfilled
|
||||
} catch {
|
||||
patchResult.undo()
|
||||
|
||||
/**
|
||||
* Alternatively, on failure you can invalidate the corresponding cache tags
|
||||
* to trigger a re-fetch:
|
||||
* dispatch(api.util.invalidateTags(['Post']))
|
||||
*/
|
||||
}
|
||||
},
|
||||
invalidatesTags: ['Me'],
|
||||
}),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const { useLoginMutation, useMeQuery, useLogoutMutation } = authApi
|
||||
51
src/services/base-api-with-refetch.ts
Normal file
51
src/services/base-api-with-refetch.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { BaseQueryFn, FetchArgs, fetchBaseQuery, FetchBaseQueryError } from '@reduxjs/toolkit/query'
|
||||
import { Mutex } from 'async-mutex'
|
||||
|
||||
const baseUrl = 'http://localhost:3333'
|
||||
|
||||
// Create a new mutex
|
||||
const mutex = new Mutex()
|
||||
|
||||
const baseQuery = fetchBaseQuery({
|
||||
baseUrl,
|
||||
credentials: 'include',
|
||||
// prepareHeaders: headers => {
|
||||
// headers.append('x-short-access-token', 'true')
|
||||
// },
|
||||
})
|
||||
|
||||
export const customFetchBase: BaseQueryFn<
|
||||
string | FetchArgs,
|
||||
unknown,
|
||||
FetchBaseQueryError
|
||||
> = async (args, api, extraOptions) => {
|
||||
// wait until the mutex is available without locking it
|
||||
await mutex.waitForUnlock()
|
||||
let result = await baseQuery(args, api, extraOptions)
|
||||
|
||||
if (result.error?.status === 401) {
|
||||
if (!mutex.isLocked()) {
|
||||
const release = await mutex.acquire()
|
||||
|
||||
try {
|
||||
const refreshResult = await baseQuery(
|
||||
{ url: 'v1/auth/refresh-token', method: 'POST' },
|
||||
api,
|
||||
extraOptions
|
||||
)
|
||||
|
||||
if (refreshResult?.meta?.response?.status === 204) {
|
||||
// Retry the initial query
|
||||
result = await baseQuery(args, api, extraOptions)
|
||||
}
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
} else {
|
||||
await mutex.waitForUnlock()
|
||||
result = await baseQuery(args, api, extraOptions)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -1,14 +1,10 @@
|
||||
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
|
||||
import { createApi } from '@reduxjs/toolkit/query/react'
|
||||
|
||||
import { customFetchBase } from '@/services/base-api-with-refetch.ts'
|
||||
|
||||
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')
|
||||
},
|
||||
}),
|
||||
tagTypes: ['Decks', 'Me'],
|
||||
baseQuery: customFetchBase,
|
||||
endpoints: () => ({}),
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ const initialState = {
|
||||
itemsPerPage: 10,
|
||||
currentPage: 1,
|
||||
searchByName: '',
|
||||
orderBy: 'created-desc',
|
||||
}
|
||||
|
||||
export const decksSlice = createSlice({
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { baseApi } from '@/services/base-api.ts'
|
||||
import { CreateDeckArgs, Deck, DecksResponse, GetDecksArgs } from '@/services/decks/types.ts'
|
||||
import {
|
||||
CreateDeckArgs,
|
||||
Deck,
|
||||
DecksResponse,
|
||||
DeleteDeckArgs,
|
||||
GetDecksArgs,
|
||||
} from '@/services/decks/types.ts'
|
||||
import { RootState } from '@/services/store.ts'
|
||||
|
||||
const decksApi = baseApi.injectEndpoints({
|
||||
endpoints: builder => {
|
||||
@@ -15,11 +22,73 @@ const decksApi = baseApi.injectEndpoints({
|
||||
providesTags: ['Decks'],
|
||||
}),
|
||||
createDeck: builder.mutation<Deck, CreateDeckArgs>({
|
||||
query: ({ name }) => {
|
||||
query: data => {
|
||||
return {
|
||||
url: 'v1/decks',
|
||||
method: 'POST',
|
||||
body: { name },
|
||||
body: data,
|
||||
}
|
||||
},
|
||||
async onQueryStarted(_, { dispatch, getState, queryFulfilled }) {
|
||||
const state = getState() as RootState
|
||||
|
||||
const { searchByName, orderBy, currentPage, itemsPerPage } = state.decksSlice
|
||||
|
||||
try {
|
||||
const res = await queryFulfilled
|
||||
|
||||
dispatch(
|
||||
decksApi.util.updateQueryData(
|
||||
'getDecks',
|
||||
{ name: searchByName, orderBy, currentPage, itemsPerPage },
|
||||
draft => {
|
||||
draft.items.pop()
|
||||
draft.items.unshift(res.data)
|
||||
}
|
||||
)
|
||||
)
|
||||
} catch {
|
||||
// patchResult.undo()
|
||||
/**
|
||||
* Alternatively, on failure you can invalidate the corresponding cache tags
|
||||
* to trigger a re-fetch:
|
||||
* dispatch(api.util.invalidateTags(['Post']))
|
||||
*/
|
||||
}
|
||||
},
|
||||
invalidatesTags: ['Decks'],
|
||||
}),
|
||||
deleteDeck: builder.mutation<void, DeleteDeckArgs>({
|
||||
query: ({ id }) => {
|
||||
return {
|
||||
url: `v1/decks/${id}`,
|
||||
method: 'DELETE',
|
||||
}
|
||||
},
|
||||
async onQueryStarted({ id }, { dispatch, getState, queryFulfilled }) {
|
||||
const state = getState() as RootState
|
||||
|
||||
const { searchByName, orderBy, currentPage, itemsPerPage } = state.decksSlice
|
||||
|
||||
const patchResult = dispatch(
|
||||
decksApi.util.updateQueryData(
|
||||
'getDecks',
|
||||
{ name: searchByName, orderBy, currentPage, itemsPerPage },
|
||||
draft => {
|
||||
draft.items = draft.items.filter(deck => deck.id !== id)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
try {
|
||||
await queryFulfilled
|
||||
} catch {
|
||||
patchResult.undo()
|
||||
/**
|
||||
* Alternatively, on failure you can invalidate the corresponding cache tags
|
||||
* to trigger a re-fetch:
|
||||
* dispatch(api.util.invalidateTags(['Post']))
|
||||
*/
|
||||
}
|
||||
},
|
||||
invalidatesTags: ['Decks'],
|
||||
@@ -28,4 +97,4 @@ const decksApi = baseApi.injectEndpoints({
|
||||
},
|
||||
})
|
||||
|
||||
export const { useGetDecksQuery, useCreateDeckMutation } = decksApi
|
||||
export const { useGetDecksQuery, useCreateDeckMutation, useDeleteDeckMutation } = decksApi
|
||||
|
||||
@@ -11,6 +11,7 @@ export type GetDecksArgs = PaginatedRequest<{
|
||||
export type CreateDeckArgs = {
|
||||
name: string
|
||||
}
|
||||
export type DeleteDeckArgs = Pick<Deck, 'id'>
|
||||
export interface Author {
|
||||
id: string
|
||||
name: string
|
||||
|
||||
Reference in New Issue
Block a user