mirror of
https://github.com/ershisan99/flashcards-example-project.git
synced 2025-12-16 12:33:18 +00:00
live
This commit is contained in:
12347
pnpm-lock.yaml
generated
12347
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
|
|
||||||
import { ControlledCheckbox, ControlledTextField, Dialog, DialogProps } from '@/components'
|
import { Button, ControlledCheckbox, ControlledTextField, Dialog, DialogProps } from '@/components'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
@@ -14,8 +15,8 @@ const newDeckSchema = z.object({
|
|||||||
type FormValues = z.infer<typeof newDeckSchema>
|
type FormValues = z.infer<typeof newDeckSchema>
|
||||||
|
|
||||||
type Props = Pick<DialogProps, 'onCancel' | 'onOpenChange' | 'open'> & {
|
type Props = Pick<DialogProps, 'onCancel' | 'onOpenChange' | 'open'> & {
|
||||||
defaultValues?: FormValues
|
defaultValues?: FormValues & { cover?: null | string }
|
||||||
onConfirm: (data: FormValues) => void
|
onConfirm: (data: FormValues & { cover?: File | null }) => void
|
||||||
}
|
}
|
||||||
export const DeckDialog = ({
|
export const DeckDialog = ({
|
||||||
defaultValues = { isPrivate: false, name: '' },
|
defaultValues = { isPrivate: false, name: '' },
|
||||||
@@ -23,12 +24,34 @@ export const DeckDialog = ({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
...dialogProps
|
...dialogProps
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
|
const [cover, setCover] = useState<File | null>(null)
|
||||||
|
const [preview, setPreview] = useState<null | string>('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (defaultValues?.cover) {
|
||||||
|
setPreview(defaultValues?.cover)
|
||||||
|
}
|
||||||
|
}, [defaultValues?.cover])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (cover) {
|
||||||
|
const newPreview = URL.createObjectURL(cover)
|
||||||
|
|
||||||
|
if (preview) {
|
||||||
|
URL.revokeObjectURL(preview)
|
||||||
|
}
|
||||||
|
setPreview(newPreview)
|
||||||
|
|
||||||
|
return () => URL.revokeObjectURL(newPreview)
|
||||||
|
}
|
||||||
|
}, [cover])
|
||||||
|
|
||||||
const { control, handleSubmit, reset } = useForm<FormValues>({
|
const { control, handleSubmit, reset } = useForm<FormValues>({
|
||||||
defaultValues,
|
defaultValues,
|
||||||
resolver: zodResolver(newDeckSchema),
|
resolver: zodResolver(newDeckSchema),
|
||||||
})
|
})
|
||||||
const onSubmit = handleSubmit(data => {
|
const onSubmit = handleSubmit(data => {
|
||||||
onConfirm(data)
|
onConfirm({ ...data, cover })
|
||||||
dialogProps.onOpenChange?.(false)
|
dialogProps.onOpenChange?.(false)
|
||||||
reset()
|
reset()
|
||||||
})
|
})
|
||||||
@@ -40,6 +63,23 @@ export const DeckDialog = ({
|
|||||||
return (
|
return (
|
||||||
<Dialog {...dialogProps} onCancel={handleCancel} onConfirm={onSubmit} title={'Create new deck'}>
|
<Dialog {...dialogProps} onCancel={handleCancel} onConfirm={onSubmit} title={'Create new deck'}>
|
||||||
<form className={s.content} onSubmit={onSubmit}>
|
<form className={s.content} onSubmit={onSubmit}>
|
||||||
|
{preview && <img alt={'Deck cover'} src={preview.toString()} />}
|
||||||
|
<input
|
||||||
|
accept={'image/*'}
|
||||||
|
onChange={e => setCover(e.target.files?.[0] ?? null)}
|
||||||
|
type={'file'}
|
||||||
|
/>
|
||||||
|
{cover && (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setCover(null)
|
||||||
|
setPreview(null)
|
||||||
|
}}
|
||||||
|
type={'button'}
|
||||||
|
>
|
||||||
|
Remove cover
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<ControlledTextField control={control} label={'Deck name'} name={'name'} />
|
<ControlledTextField control={control} label={'Deck name'} name={'name'} />
|
||||||
<ControlledCheckbox
|
<ControlledCheckbox
|
||||||
control={control}
|
control={control}
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import { Pagination } from '@/components/ui/pagination'
|
|||||||
import { useGetDeckByIdQuery, useGetDeckCardsQuery } from '@/services'
|
import { useGetDeckByIdQuery, useGetDeckCardsQuery } from '@/services'
|
||||||
|
|
||||||
export const DeckPage = () => {
|
export const DeckPage = () => {
|
||||||
const { deckId } = useParams()
|
const params = useParams()
|
||||||
|
const deckId = params.deckId ?? ''
|
||||||
const [currentPage, setCurrentPage] = useState(1)
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
const { data: deckData } = useGetDeckByIdQuery({ id: deckId || '' })
|
const { data: deckData } = useGetDeckByIdQuery({ id: deckId })
|
||||||
const { data: cardsData } = useGetDeckCardsQuery({ id: deckId || '' })
|
const { data: cardsData } = useGetDeckCardsQuery({ id: deckId })
|
||||||
|
|
||||||
const learnLink = `/decks/${deckId}/learn`
|
const learnLink = `/decks/${deckId}/learn`
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
useCreateDeckMutation,
|
useCreateDeckMutation,
|
||||||
useDeleteDeckMutation,
|
useDeleteDeckMutation,
|
||||||
useGetDecksQuery,
|
useGetDecksQuery,
|
||||||
|
useGetMinMaxCardsQuery,
|
||||||
useUpdateDeckMutation,
|
useUpdateDeckMutation,
|
||||||
} from '@/services/decks'
|
} from '@/services/decks'
|
||||||
|
|
||||||
@@ -43,20 +44,26 @@ export const DecksPage = () => {
|
|||||||
|
|
||||||
const currentUserId = me?.id
|
const currentUserId = me?.id
|
||||||
const authorId = currentTab === 'my' ? currentUserId : undefined
|
const authorId = currentTab === 'my' ? currentUserId : undefined
|
||||||
const { currentData: decksCurrentData, data: decksData } = useGetDecksQuery({
|
const { data: minMaxCardsData } = useGetMinMaxCardsQuery()
|
||||||
authorId,
|
const { currentData: decksCurrentData, data: decksData } = useGetDecksQuery(
|
||||||
currentPage,
|
{
|
||||||
maxCardsCount,
|
authorId,
|
||||||
minCardsCount,
|
currentPage,
|
||||||
name: search,
|
maxCardsCount,
|
||||||
orderBy: sort ? `${sort.key}-${sort.direction}` : undefined,
|
minCardsCount,
|
||||||
})
|
name: search,
|
||||||
|
orderBy: sort ? `${sort.key}-${sort.direction}` : undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
skip: !minMaxCardsData,
|
||||||
|
}
|
||||||
|
)
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
setCurrentPage(null)
|
setCurrentPage(null)
|
||||||
setSearch(null)
|
setSearch(null)
|
||||||
setMinCards(null)
|
setMinCards(null)
|
||||||
setMaxCards(null)
|
setMaxCards(null)
|
||||||
setRangeValue([0, decks?.maxCardsCount ?? null])
|
setRangeValue([minMaxCardsData?.min ?? 0, minMaxCardsData?.max ?? null])
|
||||||
setSort(null)
|
setSort(null)
|
||||||
}
|
}
|
||||||
const decks = decksCurrentData ?? decksData
|
const decks = decksCurrentData ?? decksData
|
||||||
@@ -144,8 +151,8 @@ export const DecksPage = () => {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<Slider
|
<Slider
|
||||||
max={decks?.maxCardsCount || 0}
|
max={minMaxCardsData?.max || 0}
|
||||||
min={0}
|
min={minMaxCardsData?.min || 0}
|
||||||
onValueChange={setRangeValue}
|
onValueChange={setRangeValue}
|
||||||
onValueCommit={handleSliderCommitted}
|
onValueCommit={handleSliderCommitted}
|
||||||
value={rangeValue}
|
value={rangeValue}
|
||||||
|
|||||||
10
src/pages/learn-page/learn.page.module.scss
Normal file
10
src/pages/learn-page/learn.page.module.scss
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
margin: 84px auto 0;
|
||||||
|
padding: 33px 36px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
19
src/pages/learn-page/learn.page.tsx
Normal file
19
src/pages/learn-page/learn.page.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
|
||||||
|
import { Card, Typography } from '@/components'
|
||||||
|
import { useGetDeckByIdQuery } from '@/services'
|
||||||
|
|
||||||
|
import s from './learn.page.module.scss'
|
||||||
|
|
||||||
|
export const LearnPage = () => {
|
||||||
|
const { deckId } = useParams()
|
||||||
|
const { data: deckData } = useGetDeckByIdQuery({ id: deckId || '' })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={s.card}>
|
||||||
|
<Typography className={s.heading} variant={'large'}>
|
||||||
|
Learn {deckData?.name}
|
||||||
|
</Typography>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
|
|
||||||
import { Layout, useAuthContext } from '@/components/layout'
|
import { Layout, useAuthContext } from '@/components/layout'
|
||||||
import { DeckPage } from '@/pages/deck-page/deck-page'
|
import { DeckPage } from '@/pages/deck-page/deck-page'
|
||||||
|
import { LearnPage } from '@/pages/learn-page/learn.page'
|
||||||
|
|
||||||
import { DecksPage, SignInPage } from './pages'
|
import { DecksPage, SignInPage } from './pages'
|
||||||
|
|
||||||
@@ -32,9 +33,13 @@ const privateRoutes: RouteObject[] = [
|
|||||||
element: <DeckPage />,
|
element: <DeckPage />,
|
||||||
path: '/decks/:deckId',
|
path: '/decks/:deckId',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
element: <LearnPage />,
|
||||||
|
path: '/decks/:deckId/learn',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
import { baseApi } from '..'
|
import { flashcardsApi } from '..'
|
||||||
import { LoginArgs, User } from './auth.types'
|
import { LoginArgs, LoginResponse, User } from './auth.types'
|
||||||
|
|
||||||
export const authService = baseApi.injectEndpoints({
|
export const authService = flashcardsApi.injectEndpoints({
|
||||||
endpoints: builder => ({
|
endpoints: builder => ({
|
||||||
login: builder.mutation<void, LoginArgs>({
|
login: builder.mutation<LoginResponse, LoginArgs>({
|
||||||
invalidatesTags: ['Me'],
|
// invalidatesTags: ['Me'],
|
||||||
|
async onQueryStarted(_, { queryFulfilled }) {
|
||||||
|
const { data } = await queryFulfilled
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem('accessToken', data.accessToken.trim())
|
||||||
|
localStorage.setItem('refreshToken', data.refreshToken.trim())
|
||||||
|
},
|
||||||
query: body => ({
|
query: body => ({
|
||||||
body,
|
body,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ export type LoginArgs = {
|
|||||||
password: string
|
password: string
|
||||||
rememberMe?: boolean
|
rememberMe?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type LoginResponse = {
|
||||||
|
accessToken: string
|
||||||
|
refreshToken: string
|
||||||
|
}
|
||||||
|
|
||||||
export type User = {
|
export type User = {
|
||||||
avatar: null | string
|
avatar: null | string
|
||||||
created: string
|
created: string
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import { baseQueryWithReauth } from '@/services/base-query-with-reauth'
|
|
||||||
import { createApi } from '@reduxjs/toolkit/query/react'
|
|
||||||
|
|
||||||
export const baseApi = createApi({
|
|
||||||
baseQuery: baseQueryWithReauth,
|
|
||||||
endpoints: () => ({}),
|
|
||||||
reducerPath: 'baseApi',
|
|
||||||
tagTypes: ['Decks', 'Me'],
|
|
||||||
})
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
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,41 +1,60 @@
|
|||||||
import {
|
import {
|
||||||
CardsResponse,
|
CardsResponse,
|
||||||
CreateDeckArgs,
|
CreateDeckArgs,
|
||||||
|
DeckMinMaxCardsResponse,
|
||||||
DeckResponse,
|
DeckResponse,
|
||||||
DecksResponse,
|
DecksResponse,
|
||||||
GetDecksArgs,
|
GetDecksArgs,
|
||||||
UpdateDeckArgs,
|
UpdateDeckArgs,
|
||||||
baseApi,
|
flashcardsApi,
|
||||||
} from '@/services'
|
} from '@/services'
|
||||||
import { RootState } from '@/services/store'
|
|
||||||
import { getValuable } from '@/utils'
|
import { getValuable } from '@/utils'
|
||||||
|
|
||||||
const decksService = baseApi.injectEndpoints({
|
const decksService = flashcardsApi.injectEndpoints({
|
||||||
endpoints: builder => ({
|
endpoints: builder => ({
|
||||||
createDeck: builder.mutation<DeckResponse, CreateDeckArgs>({
|
createDeck: builder.mutation<DeckResponse, CreateDeckArgs>({
|
||||||
invalidatesTags: ['Decks'],
|
invalidatesTags: ['Decks'],
|
||||||
async onQueryStarted(_, { dispatch, getState, queryFulfilled }) {
|
async onQueryStarted(_, { dispatch, getState, queryFulfilled }) {
|
||||||
const res = await queryFulfilled
|
const invalidateBy = decksService.util.selectInvalidatedBy(getState(), [{ type: 'Decks' }])
|
||||||
|
|
||||||
for (const { endpointName, originalArgs } of decksService.util.selectInvalidatedBy(
|
try {
|
||||||
getState(),
|
const { data } = await queryFulfilled
|
||||||
[{ type: 'Decks' }]
|
|
||||||
)) {
|
invalidateBy.forEach(({ originalArgs }) => {
|
||||||
if (endpointName !== 'getDecks') {
|
dispatch(
|
||||||
continue
|
decksService.util.updateQueryData('getDecks', originalArgs, draft => {
|
||||||
}
|
if (originalArgs.currentPage !== 1) {
|
||||||
dispatch(
|
return
|
||||||
decksService.util.updateQueryData(endpointName, originalArgs, draft => {
|
}
|
||||||
draft.items.unshift(res.data)
|
draft.items.unshift(data)
|
||||||
})
|
draft.items.pop()
|
||||||
)
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
query: ({ cover, isPrivate, name }) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
|
||||||
|
formData.append('name', name)
|
||||||
|
|
||||||
|
if (isPrivate) {
|
||||||
|
formData.append('isPrivate', isPrivate + '')
|
||||||
|
}
|
||||||
|
if (cover) {
|
||||||
|
formData.append('cover', cover)
|
||||||
|
} else if (cover === null) {
|
||||||
|
formData.append('cover', '')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
body: formData,
|
||||||
|
method: 'POST',
|
||||||
|
url: `v1/decks`,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
query: body => ({
|
|
||||||
body,
|
|
||||||
method: 'POST',
|
|
||||||
url: `v1/decks`,
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
deleteDeck: builder.mutation<void, { id: string }>({
|
deleteDeck: builder.mutation<void, { id: string }>({
|
||||||
invalidatesTags: ['Decks'],
|
invalidatesTags: ['Decks'],
|
||||||
@@ -45,6 +64,7 @@ const decksService = baseApi.injectEndpoints({
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
getDeckById: builder.query<DeckResponse, { id: string }>({
|
getDeckById: builder.query<DeckResponse, { id: string }>({
|
||||||
|
providesTags: ['Decks'],
|
||||||
query: ({ id }) => `v1/decks/${id}`,
|
query: ({ id }) => `v1/decks/${id}`,
|
||||||
}),
|
}),
|
||||||
getDeckCards: builder.query<CardsResponse, { id: string }>({
|
getDeckCards: builder.query<CardsResponse, { id: string }>({
|
||||||
@@ -55,53 +75,64 @@ const decksService = baseApi.injectEndpoints({
|
|||||||
query: args => {
|
query: args => {
|
||||||
return {
|
return {
|
||||||
params: args ? getValuable(args) : undefined,
|
params: args ? getValuable(args) : undefined,
|
||||||
url: `v1/decks`,
|
url: 'v2/decks',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
getMinMaxCards: builder.query<DeckMinMaxCardsResponse, void>({
|
||||||
|
query: () => `v2/decks/min-max-cards`,
|
||||||
|
}),
|
||||||
updateDeck: builder.mutation<DeckResponse, UpdateDeckArgs>({
|
updateDeck: builder.mutation<DeckResponse, UpdateDeckArgs>({
|
||||||
invalidatesTags: ['Decks'],
|
invalidatesTags: ['Decks'],
|
||||||
async onQueryStarted({ id, ...patch }, { dispatch, getState, queryFulfilled }) {
|
async onQueryStarted({ cover, id, ...args }, { dispatch, getState, queryFulfilled }) {
|
||||||
const state = getState() as RootState
|
const invalidateBy = decksService.util.selectInvalidatedBy(getState(), [{ type: 'Decks' }])
|
||||||
|
const patchResults: any[] = []
|
||||||
|
|
||||||
const minCardsCount = state.decks.minCards
|
invalidateBy.forEach(({ originalArgs }) => {
|
||||||
const search = state.decks.search
|
patchResults.push(
|
||||||
const currentPage = state.decks.currentPage
|
dispatch(
|
||||||
const maxCardsCount = state.decks.maxCards
|
decksService.util.updateQueryData('getDecks', originalArgs, draft => {
|
||||||
const authorId = state.decks.authorId
|
const itemToUpdateIndex = draft.items.findIndex(deck => deck.id === id)
|
||||||
|
|
||||||
const patchResult = dispatch(
|
if (itemToUpdateIndex === -1) {
|
||||||
decksService.util.updateQueryData(
|
return
|
||||||
'getDecks',
|
}
|
||||||
{
|
|
||||||
authorId,
|
|
||||||
currentPage,
|
|
||||||
maxCardsCount,
|
|
||||||
minCardsCount,
|
|
||||||
name: search,
|
|
||||||
},
|
|
||||||
draft => {
|
|
||||||
const deck = draft.items.find(deck => deck.id === id)
|
|
||||||
|
|
||||||
if (!deck) {
|
Object.assign(draft.items[itemToUpdateIndex], args)
|
||||||
return
|
})
|
||||||
}
|
)
|
||||||
Object.assign(deck, patch)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
)
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await queryFulfilled
|
await queryFulfilled
|
||||||
} catch {
|
} catch (e) {
|
||||||
patchResult.undo()
|
patchResults.forEach(patchResult => {
|
||||||
|
patchResult.undo()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
query: ({ cover, id, isPrivate, name }) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
formData.append('name', name)
|
||||||
|
}
|
||||||
|
if (isPrivate) {
|
||||||
|
formData.append('isPrivate', isPrivate.toString())
|
||||||
|
}
|
||||||
|
if (cover) {
|
||||||
|
formData.append('cover', cover)
|
||||||
|
} else if (cover === null) {
|
||||||
|
formData.append('cover', '')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
body: formData,
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `v1/decks/${id}`,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
query: ({ id, ...body }) => ({
|
|
||||||
body,
|
|
||||||
method: 'PATCH',
|
|
||||||
url: `v1/decks/${id}`,
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -112,5 +143,7 @@ export const {
|
|||||||
useGetDeckByIdQuery,
|
useGetDeckByIdQuery,
|
||||||
useGetDeckCardsQuery,
|
useGetDeckCardsQuery,
|
||||||
useGetDecksQuery,
|
useGetDecksQuery,
|
||||||
|
useGetMinMaxCardsQuery,
|
||||||
|
useLazyGetDecksQuery,
|
||||||
useUpdateDeckMutation,
|
useUpdateDeckMutation,
|
||||||
} = decksService
|
} = decksService
|
||||||
|
|||||||
@@ -28,10 +28,14 @@ export type Deck = {
|
|||||||
|
|
||||||
export type DecksResponse = {
|
export type DecksResponse = {
|
||||||
items: Deck[]
|
items: Deck[]
|
||||||
maxCardsCount: number
|
|
||||||
pagination: Pagination
|
pagination: Pagination
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DeckMinMaxCardsResponse = {
|
||||||
|
max: number
|
||||||
|
min: number
|
||||||
|
}
|
||||||
|
|
||||||
export type DeckResponse = Deck
|
export type DeckResponse = Deck
|
||||||
|
|
||||||
export type CardsResponse = {
|
export type CardsResponse = {
|
||||||
@@ -64,7 +68,7 @@ export type GetDecksArgs = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type CreateDeckArgs = {
|
export type CreateDeckArgs = {
|
||||||
cover?: string
|
cover?: File | null
|
||||||
isPrivate?: boolean
|
isPrivate?: boolean
|
||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
|||||||
9
src/services/flashcards-api.ts
Normal file
9
src/services/flashcards-api.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { baseQueryWithReauth } from '@/services/flashcards-base-query'
|
||||||
|
import { createApi } from '@reduxjs/toolkit/query/react'
|
||||||
|
|
||||||
|
export const flashcardsApi = createApi({
|
||||||
|
baseQuery: baseQueryWithReauth,
|
||||||
|
endpoints: () => ({}),
|
||||||
|
reducerPath: 'baseApi',
|
||||||
|
tagTypes: ['Decks', 'Me', 'Deck'],
|
||||||
|
})
|
||||||
79
src/services/flashcards-base-query.ts
Normal file
79
src/services/flashcards-base-query.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { router } from '@/router'
|
||||||
|
import {
|
||||||
|
BaseQueryFn,
|
||||||
|
FetchArgs,
|
||||||
|
FetchBaseQueryError,
|
||||||
|
fetchBaseQuery,
|
||||||
|
} from '@reduxjs/toolkit/query/react'
|
||||||
|
import { Mutex } from 'async-mutex'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const refreshTokenResponseSchema = z.object({
|
||||||
|
accessToken: z.string(),
|
||||||
|
refreshToken: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const mutex = new Mutex()
|
||||||
|
const baseQuery = fetchBaseQuery({
|
||||||
|
baseUrl: 'https://api.flashcards.andrii.es',
|
||||||
|
prepareHeaders: headers => {
|
||||||
|
const token = localStorage.getItem('accessToken')
|
||||||
|
|
||||||
|
if (headers.get('Authorization')) {
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
if (token) {
|
||||||
|
headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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 {
|
||||||
|
const refreshToken = localStorage.getItem('refreshToken')
|
||||||
|
|
||||||
|
const refreshResult = await baseQuery(
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${refreshToken}`,
|
||||||
|
},
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v2/auth/refresh-token',
|
||||||
|
},
|
||||||
|
api,
|
||||||
|
extraOptions
|
||||||
|
)
|
||||||
|
|
||||||
|
if (refreshResult.data) {
|
||||||
|
const refreshResultParsed = refreshTokenResponseSchema.parse(refreshResult.data)
|
||||||
|
|
||||||
|
localStorage.setItem('accessToken', refreshResultParsed.accessToken.trim())
|
||||||
|
localStorage.setItem('refreshToken', refreshResultParsed.refreshToken.trim())
|
||||||
|
|
||||||
|
// retry the initial query
|
||||||
|
result = await baseQuery(args, api, extraOptions)
|
||||||
|
} else {
|
||||||
|
router.navigate('/login')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
release()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await mutex.waitForUnlock()
|
||||||
|
result = await baseQuery(args, api, extraOptions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
export * from './base-api'
|
export * from './flashcards-api'
|
||||||
export * from './decks'
|
export * from './decks'
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ 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'
|
import { flashcardsApi } from './flashcards-api'
|
||||||
|
|
||||||
export const store = configureStore({
|
export const store = configureStore({
|
||||||
middleware: getDefaultMiddleware => getDefaultMiddleware().concat(baseApi.middleware),
|
middleware: getDefaultMiddleware => getDefaultMiddleware().concat(flashcardsApi.middleware),
|
||||||
reducer: {
|
reducer: {
|
||||||
[baseApi.reducerPath]: baseApi.reducer,
|
|
||||||
[decksSlice.name]: decksSlice.reducer,
|
[decksSlice.name]: decksSlice.reducer,
|
||||||
|
[flashcardsApi.reducerPath]: flashcardsApi.reducer,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user