This commit is contained in:
2024-05-18 21:21:20 +02:00
parent 7af2b43fed
commit 8af5be9771
17 changed files with 7195 additions and 5599 deletions

12101
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
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 { z } from 'zod'
@@ -14,8 +15,8 @@ const newDeckSchema = z.object({
type FormValues = z.infer<typeof newDeckSchema>
type Props = Pick<DialogProps, 'onCancel' | 'onOpenChange' | 'open'> & {
defaultValues?: FormValues
onConfirm: (data: FormValues) => void
defaultValues?: FormValues & { cover?: null | string }
onConfirm: (data: FormValues & { cover?: File | null }) => void
}
export const DeckDialog = ({
defaultValues = { isPrivate: false, name: '' },
@@ -23,12 +24,34 @@ export const DeckDialog = ({
onConfirm,
...dialogProps
}: 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>({
defaultValues,
resolver: zodResolver(newDeckSchema),
})
const onSubmit = handleSubmit(data => {
onConfirm(data)
onConfirm({ ...data, cover })
dialogProps.onOpenChange?.(false)
reset()
})
@@ -40,6 +63,23 @@ export const DeckDialog = ({
return (
<Dialog {...dialogProps} onCancel={handleCancel} onConfirm={onSubmit} title={'Create new deck'}>
<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'} />
<ControlledCheckbox
control={control}

View File

@@ -6,10 +6,11 @@ import { Pagination } from '@/components/ui/pagination'
import { useGetDeckByIdQuery, useGetDeckCardsQuery } from '@/services'
export const DeckPage = () => {
const { deckId } = useParams()
const params = useParams()
const deckId = params.deckId ?? ''
const [currentPage, setCurrentPage] = useState(1)
const { data: deckData } = useGetDeckByIdQuery({ id: deckId || '' })
const { data: cardsData } = useGetDeckCardsQuery({ id: deckId || '' })
const { data: deckData } = useGetDeckByIdQuery({ id: deckId })
const { data: cardsData } = useGetDeckCardsQuery({ id: deckId })
const learnLink = `/decks/${deckId}/learn`

View File

@@ -11,6 +11,7 @@ import {
useCreateDeckMutation,
useDeleteDeckMutation,
useGetDecksQuery,
useGetMinMaxCardsQuery,
useUpdateDeckMutation,
} from '@/services/decks'
@@ -43,20 +44,26 @@ export const DecksPage = () => {
const currentUserId = me?.id
const authorId = currentTab === 'my' ? currentUserId : undefined
const { currentData: decksCurrentData, data: decksData } = useGetDecksQuery({
const { data: minMaxCardsData } = useGetMinMaxCardsQuery()
const { currentData: decksCurrentData, data: decksData } = useGetDecksQuery(
{
authorId,
currentPage,
maxCardsCount,
minCardsCount,
name: search,
orderBy: sort ? `${sort.key}-${sort.direction}` : undefined,
})
},
{
skip: !minMaxCardsData,
}
)
const resetFilters = () => {
setCurrentPage(null)
setSearch(null)
setMinCards(null)
setMaxCards(null)
setRangeValue([0, decks?.maxCardsCount ?? null])
setRangeValue([minMaxCardsData?.min ?? 0, minMaxCardsData?.max ?? null])
setSort(null)
}
const decks = decksCurrentData ?? decksData
@@ -144,8 +151,8 @@ export const DecksPage = () => {
</TabsList>
</Tabs>
<Slider
max={decks?.maxCardsCount || 0}
min={0}
max={minMaxCardsData?.max || 0}
min={minMaxCardsData?.min || 0}
onValueChange={setRangeValue}
onValueCommit={handleSliderCommitted}
value={rangeValue}

View File

@@ -0,0 +1,10 @@
.card {
width: 100%;
max-width: 420px;
margin: 84px auto 0;
padding: 33px 36px 48px;
}
.heading {
text-align: center;
}

View 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>
)
}

View File

@@ -8,6 +8,7 @@ import {
import { Layout, useAuthContext } from '@/components/layout'
import { DeckPage } from '@/pages/deck-page/deck-page'
import { LearnPage } from '@/pages/learn-page/learn.page'
import { DecksPage, SignInPage } from './pages'
@@ -32,9 +33,13 @@ const privateRoutes: RouteObject[] = [
element: <DeckPage />,
path: '/decks/:deckId',
},
{
element: <LearnPage />,
path: '/decks/:deckId/learn',
},
]
const router = createBrowserRouter([
export const router = createBrowserRouter([
{
children: [
{

View File

@@ -1,10 +1,20 @@
import { baseApi } from '..'
import { LoginArgs, User } from './auth.types'
import { flashcardsApi } from '..'
import { LoginArgs, LoginResponse, User } from './auth.types'
export const authService = baseApi.injectEndpoints({
export const authService = flashcardsApi.injectEndpoints({
endpoints: builder => ({
login: builder.mutation<void, LoginArgs>({
invalidatesTags: ['Me'],
login: builder.mutation<LoginResponse, LoginArgs>({
// 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 => ({
body,
method: 'POST',

View File

@@ -3,6 +3,12 @@ export type LoginArgs = {
password: string
rememberMe?: boolean
}
export type LoginResponse = {
accessToken: string
refreshToken: string
}
export type User = {
avatar: null | string
created: string

View File

@@ -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'],
})

View File

@@ -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
}

View File

@@ -1,41 +1,60 @@
import {
CardsResponse,
CreateDeckArgs,
DeckMinMaxCardsResponse,
DeckResponse,
DecksResponse,
GetDecksArgs,
UpdateDeckArgs,
baseApi,
flashcardsApi,
} from '@/services'
import { RootState } from '@/services/store'
import { getValuable } from '@/utils'
const decksService = baseApi.injectEndpoints({
const decksService = flashcardsApi.injectEndpoints({
endpoints: builder => ({
createDeck: builder.mutation<DeckResponse, CreateDeckArgs>({
invalidatesTags: ['Decks'],
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(
getState(),
[{ type: 'Decks' }]
)) {
if (endpointName !== 'getDecks') {
continue
}
try {
const { data } = await queryFulfilled
invalidateBy.forEach(({ originalArgs }) => {
dispatch(
decksService.util.updateQueryData(endpointName, originalArgs, draft => {
draft.items.unshift(res.data)
decksService.util.updateQueryData('getDecks', originalArgs, draft => {
if (originalArgs.currentPage !== 1) {
return
}
draft.items.unshift(data)
draft.items.pop()
})
)
})
} catch (e) {
console.log(e)
}
},
query: body => ({
body,
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`,
}),
}
},
}),
deleteDeck: builder.mutation<void, { id: string }>({
invalidatesTags: ['Decks'],
@@ -45,6 +64,7 @@ const decksService = baseApi.injectEndpoints({
}),
}),
getDeckById: builder.query<DeckResponse, { id: string }>({
providesTags: ['Decks'],
query: ({ id }) => `v1/decks/${id}`,
}),
getDeckCards: builder.query<CardsResponse, { id: string }>({
@@ -55,53 +75,64 @@ const decksService = baseApi.injectEndpoints({
query: args => {
return {
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>({
invalidatesTags: ['Decks'],
async onQueryStarted({ id, ...patch }, { dispatch, getState, queryFulfilled }) {
const state = getState() as RootState
async onQueryStarted({ cover, id, ...args }, { dispatch, getState, queryFulfilled }) {
const invalidateBy = decksService.util.selectInvalidatedBy(getState(), [{ type: 'Decks' }])
const patchResults: any[] = []
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
invalidateBy.forEach(({ originalArgs }) => {
patchResults.push(
dispatch(
decksService.util.updateQueryData('getDecks', originalArgs, draft => {
const itemToUpdateIndex = draft.items.findIndex(deck => deck.id === id)
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) {
if (itemToUpdateIndex === -1) {
return
}
Object.assign(deck, patch)
}
Object.assign(draft.items[itemToUpdateIndex], args)
})
)
)
})
try {
await queryFulfilled
} catch {
} catch (e) {
patchResults.forEach(patchResult => {
patchResult.undo()
})
}
},
query: ({ id, ...body }) => ({
body,
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}`,
}),
}
},
}),
}),
})
@@ -112,5 +143,7 @@ export const {
useGetDeckByIdQuery,
useGetDeckCardsQuery,
useGetDecksQuery,
useGetMinMaxCardsQuery,
useLazyGetDecksQuery,
useUpdateDeckMutation,
} = decksService

View File

@@ -28,10 +28,14 @@ export type Deck = {
export type DecksResponse = {
items: Deck[]
maxCardsCount: number
pagination: Pagination
}
export type DeckMinMaxCardsResponse = {
max: number
min: number
}
export type DeckResponse = Deck
export type CardsResponse = {
@@ -64,7 +68,7 @@ export type GetDecksArgs = {
}
export type CreateDeckArgs = {
cover?: string
cover?: File | null
isPrivate?: boolean
name: string
}

View 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'],
})

View 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
}

View File

@@ -1,2 +1,2 @@
export * from './base-api'
export * from './flashcards-api'
export * from './decks'

View File

@@ -4,13 +4,13 @@ import { decksSlice } from '@/services/decks/decks.slice'
import { configureStore } from '@reduxjs/toolkit'
import { setupListeners } from '@reduxjs/toolkit/query/react'
import { baseApi } from './base-api'
import { flashcardsApi } from './flashcards-api'
export const store = configureStore({
middleware: getDefaultMiddleware => getDefaultMiddleware().concat(baseApi.middleware),
middleware: getDefaultMiddleware => getDefaultMiddleware().concat(flashcardsApi.middleware),
reducer: {
[baseApi.reducerPath]: baseApi.reducer,
[decksSlice.name]: decksSlice.reducer,
[flashcardsApi.reducerPath]: flashcardsApi.reducer,
},
})