Compare commits
10 Commits
e328b64043
...
d6629e5176
Author | SHA1 | Date | |
---|---|---|---|
![]() |
d6629e5176 | ||
![]() |
21c154ae47 | ||
![]() |
5d1def13db | ||
![]() |
a3376a8cc1 | ||
![]() |
c54d3832a3 | ||
![]() |
c099275b31 | ||
![]() |
9cc05d0a7a | ||
![]() |
af683d9ee9 | ||
![]() |
56dffa2ee8 | ||
![]() |
8ce916731f |
@ -1,97 +0,0 @@
|
||||
import { QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import { useRouter } from '@tanstack/react-router'
|
||||
import React, { useState } from 'react'
|
||||
import { ApiError } from './api/ApiError'
|
||||
import ExpiredTokenError from './api/ExpiredTokenError'
|
||||
import refresh, { RefreshTokenError } from './api/refresh'
|
||||
import LoginView from './api/types/LoginView'
|
||||
import { useAuth } from './auth'
|
||||
|
||||
const AuthAwareQueryClientProvider = ({ children }: React.PropsWithChildren) => {
|
||||
const { putToken, clearToken } = useAuth()
|
||||
const router = useRouter()
|
||||
const [currentlyRefreshing, setCurrentlyRefreshing] = useState(false)
|
||||
|
||||
const doRefresh = async () => {
|
||||
if (!currentlyRefreshing) {
|
||||
console.log('starting refresh')
|
||||
setCurrentlyRefreshing(true)
|
||||
let refreshResult: LoginView
|
||||
try {
|
||||
refreshResult = await refresh()
|
||||
} catch (error) {
|
||||
if (error instanceof RefreshTokenError) {
|
||||
console.log(`RefreshTokenError: ${error.reason}`)
|
||||
setCurrentlyRefreshing(false)
|
||||
clearToken()
|
||||
await router.navigate({
|
||||
to: '/login',
|
||||
search: {
|
||||
reason: error.reason,
|
||||
redirect: router.state.location.href
|
||||
}
|
||||
})
|
||||
console.log('post-navigate')
|
||||
return
|
||||
} else {
|
||||
setCurrentlyRefreshing(false)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
putToken(refreshResult.accessToken, refreshResult.username)
|
||||
setCurrentlyRefreshing(false)
|
||||
console.log('refresh done')
|
||||
}
|
||||
}
|
||||
|
||||
const [queryClient] = useState<QueryClient>(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
mutations: {
|
||||
onError(error) {
|
||||
if (error instanceof ExpiredTokenError) {
|
||||
doRefresh()
|
||||
}
|
||||
}
|
||||
},
|
||||
queries: {
|
||||
retry(failureCount, error) {
|
||||
if (
|
||||
error instanceof ExpiredTokenError ||
|
||||
(error instanceof ApiError && error.status === 404)
|
||||
) {
|
||||
return false
|
||||
} else {
|
||||
return failureCount <= 3
|
||||
}
|
||||
},
|
||||
retryDelay(failureCount, error) {
|
||||
if (error instanceof ExpiredTokenError) {
|
||||
return 0
|
||||
} else {
|
||||
return failureCount * 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
queryCache: new QueryCache({
|
||||
onError(error) {
|
||||
if (error instanceof ExpiredTokenError) {
|
||||
doRefresh()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
<ReactQueryDevtools position="right" buttonPosition="top-right" />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default AuthAwareQueryClientProvider
|
34
src/AuthProvider.tsx
Normal file
34
src/AuthProvider.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { createContext, PropsWithChildren, useContext, useState } from 'react'
|
||||
import AccessToken from './types/AccessToken'
|
||||
|
||||
export interface AuthContextType {
|
||||
accessToken: AccessToken | null
|
||||
putToken: (token: AccessToken | null) => void
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null)
|
||||
|
||||
export const AuthProvider = ({ children }: PropsWithChildren) => {
|
||||
const [accessToken, setAccessToken] = useState<AccessToken | null>(null)
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
accessToken,
|
||||
putToken: token => {
|
||||
console.log(`token: ${token !== null ? token.token : null}`)
|
||||
setAccessToken(token)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useAuth = () => {
|
||||
const auth = useContext(AuthContext)
|
||||
if (!auth) {
|
||||
throw new Error('useAuth must be used in an AuthProvider context')
|
||||
}
|
||||
return auth
|
||||
}
|
73
src/RefreshProvider.tsx
Normal file
73
src/RefreshProvider.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import { useRouter } from '@tanstack/react-router'
|
||||
import { createContext, PropsWithChildren, useCallback, useContext, useRef } from 'react'
|
||||
import { ApiError } from './api/ApiError'
|
||||
import refresh, { RefreshTokenError } from './api/refresh'
|
||||
import { useAuth } from './AuthProvider'
|
||||
import Refresh from './types/Refresh'
|
||||
|
||||
export interface RefreshContextType {
|
||||
refresh: Refresh
|
||||
}
|
||||
|
||||
const RefreshContext = createContext<RefreshContextType | null>(null)
|
||||
|
||||
export const useRefresh = () => {
|
||||
const refreshContext = useContext(RefreshContext)
|
||||
if (refreshContext === null) {
|
||||
throw new Error('refreshContext is null')
|
||||
}
|
||||
return refreshContext.refresh
|
||||
}
|
||||
|
||||
const RefreshProvider = ({ children }: PropsWithChildren) => {
|
||||
const { putToken } = useAuth()
|
||||
const router = useRouter()
|
||||
const refreshing = useRef(false)
|
||||
|
||||
const doRefresh: Refresh = useCallback(async () => {
|
||||
if (!refreshing.current) {
|
||||
refreshing.current = true
|
||||
try {
|
||||
const { accessToken: token, expires, username } = await refresh()
|
||||
putToken({
|
||||
token,
|
||||
expires,
|
||||
username
|
||||
})
|
||||
refreshing.current = false
|
||||
return {
|
||||
token,
|
||||
expires,
|
||||
username
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof RefreshTokenError) {
|
||||
router.navigate({
|
||||
to: '/login',
|
||||
search: {
|
||||
reason: error.reason,
|
||||
redirect: router.state.location.href
|
||||
}
|
||||
})
|
||||
} else if (error instanceof ApiError) {
|
||||
console.error(error)
|
||||
}
|
||||
putToken(null)
|
||||
refreshing.current = false
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, [putToken])
|
||||
|
||||
return (
|
||||
<RefreshContext.Provider
|
||||
value={{
|
||||
refresh: doRefresh
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</RefreshContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default RefreshProvider
|
@ -1,4 +1,4 @@
|
||||
import { AuthContextType } from './auth'
|
||||
import { AuthContextType } from './AuthProvider'
|
||||
|
||||
export default interface RouterContext {
|
||||
auth: AuthContextType
|
||||
|
@ -1,25 +1,21 @@
|
||||
import { ApiError } from './ApiError'
|
||||
import ExpiredTokenError from './ExpiredTokenError'
|
||||
import AccessToken from '../types/AccessToken'
|
||||
import Refresh from '../types/Refresh'
|
||||
import apiCallFactory from './apiCallFactory'
|
||||
|
||||
export interface AddStarDeps {
|
||||
token: string
|
||||
accessToken: AccessToken
|
||||
refresh: Refresh
|
||||
username: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
const addStar = async ({ slug, token, username }: AddStarDeps): Promise<void> => {
|
||||
const headers = new Headers()
|
||||
headers.set('Authorization', `Bearer ${token}`)
|
||||
const response = await fetch(import.meta.env.VITE_MME_API_URL + `/recipes/${username}/${slug}/star`, {
|
||||
headers,
|
||||
method: 'POST',
|
||||
mode: 'cors'
|
||||
const doAddStar = apiCallFactory<void>('POST')
|
||||
|
||||
const addStar = ({ accessToken, refresh, username, slug }: AddStarDeps) =>
|
||||
doAddStar({
|
||||
accessToken,
|
||||
endpoint: `/recipes/${username}/${slug}/star`,
|
||||
refresh
|
||||
})
|
||||
if (response.status === 401) {
|
||||
throw new ExpiredTokenError()
|
||||
} else if (!response.ok) {
|
||||
throw new ApiError(response.status, response.statusText)
|
||||
}
|
||||
}
|
||||
|
||||
export default addStar
|
||||
|
110
src/api/apiCallFactory.ts
Normal file
110
src/api/apiCallFactory.ts
Normal file
@ -0,0 +1,110 @@
|
||||
import AccessToken from '../types/AccessToken'
|
||||
import Refresh from '../types/Refresh'
|
||||
import { ApiError } from './ApiError'
|
||||
|
||||
export type Method = 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||||
|
||||
export type ApiCallFactoryDeps<T> = WithHandleJson<T> | WithHandleResponse<T>
|
||||
|
||||
export interface WithHandleJson<T> {
|
||||
handleJson: (raw: any) => T
|
||||
}
|
||||
|
||||
export interface WithHandleResponse<T> {
|
||||
handleResponse: (response: Response) => Promise<T> | T
|
||||
}
|
||||
|
||||
export type ApiCallDeps = {
|
||||
accessToken: AccessToken | null
|
||||
refresh: Refresh
|
||||
signal?: AbortSignal
|
||||
body?: any
|
||||
} & (WithEndpoint | WithUrl)
|
||||
|
||||
export interface WithEndpoint {
|
||||
endpoint: string
|
||||
query?: string
|
||||
}
|
||||
|
||||
export interface WithUrl {
|
||||
url: string
|
||||
}
|
||||
|
||||
export type ApiCall<T> = (deps: ApiCallDeps) => Promise<T>
|
||||
|
||||
export interface ApiCallFactory<T> {
|
||||
(method: Method): ApiCall<void>
|
||||
(method: Method, handleBody: (raw: any) => T): ApiCall<T>
|
||||
(method: Method, deps: ApiCallFactoryDeps<T>): ApiCall<T>
|
||||
}
|
||||
|
||||
const handleResult = async <T>(
|
||||
handleBodyOrDeps: ((raw: any) => T) | ApiCallFactoryDeps<T>,
|
||||
response: Response
|
||||
): Promise<T> => {
|
||||
if (typeof handleBodyOrDeps === 'function') {
|
||||
return handleBodyOrDeps(await response.json())
|
||||
} else {
|
||||
const deps = handleBodyOrDeps
|
||||
if ('handleResponse' in deps) {
|
||||
return deps.handleResponse(response)
|
||||
} else {
|
||||
return deps.handleJson(await response.json())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const apiCallFactory = <T>(
|
||||
method: Method,
|
||||
handleBodyOrDeps?: ((raw: any) => T) | ApiCallFactoryDeps<T>
|
||||
): ApiCall<typeof handleBodyOrDeps extends undefined ? void : T> => {
|
||||
return (async (deps: ApiCallDeps): Promise<void | T> => {
|
||||
const { accessToken, refresh, signal } = deps
|
||||
const headers = new Headers()
|
||||
if (accessToken) {
|
||||
headers.set('Authorization', `Bearer ${accessToken.token}`)
|
||||
}
|
||||
if (deps.body) {
|
||||
headers.set('Content-type', 'application/json')
|
||||
}
|
||||
const url =
|
||||
'url' in deps
|
||||
? deps.url
|
||||
: deps.query
|
||||
? import.meta.env.VITE_MME_API_URL + deps.endpoint + '?' + deps.query
|
||||
: import.meta.env.VITE_MME_API_URL + deps.endpoint
|
||||
const body = deps.body ? JSON.stringify(deps.body) : undefined
|
||||
const response = await fetch(url, {
|
||||
body,
|
||||
signal,
|
||||
headers,
|
||||
mode: 'cors',
|
||||
method
|
||||
})
|
||||
if (response.ok && handleBodyOrDeps) {
|
||||
return handleResult(handleBodyOrDeps, response)
|
||||
} else if (response.status === 401) {
|
||||
const newToken = await refresh()
|
||||
if (newToken === null) {
|
||||
throw new ApiError(401, 'Could not get a refreshed access token.')
|
||||
}
|
||||
headers.set('Authorization', `Bearer ${newToken.token}`)
|
||||
const retry = await fetch(url, {
|
||||
body,
|
||||
signal,
|
||||
headers,
|
||||
mode: 'cors',
|
||||
method
|
||||
})
|
||||
if (retry.ok && handleBodyOrDeps) {
|
||||
return handleResult(handleBodyOrDeps, retry)
|
||||
} else if (!retry.ok) {
|
||||
throw new ApiError(retry.status, retry.statusText)
|
||||
}
|
||||
} else if (!response.ok) {
|
||||
throw new ApiError(response.status, response.statusText)
|
||||
}
|
||||
}) as ApiCall<T>
|
||||
}
|
||||
|
||||
export default apiCallFactory
|
@ -1,29 +1,24 @@
|
||||
import { ApiError } from './ApiError'
|
||||
import ExpiredTokenError from './ExpiredTokenError'
|
||||
import AccessToken from '../types/AccessToken'
|
||||
import Refresh from '../types/Refresh'
|
||||
import apiCallFactory from './apiCallFactory'
|
||||
|
||||
export interface GetImageDeps {
|
||||
accessToken: string | null
|
||||
accessToken: AccessToken | null
|
||||
refresh: Refresh
|
||||
signal: AbortSignal
|
||||
url: string
|
||||
}
|
||||
|
||||
const getImage = async ({ accessToken, signal, url }: GetImageDeps): Promise<string> => {
|
||||
const headers = new Headers()
|
||||
if (accessToken !== null) {
|
||||
headers.set('Authorization', `Bearer ${accessToken}`)
|
||||
}
|
||||
const response = await fetch(url, {
|
||||
headers,
|
||||
mode: 'cors',
|
||||
signal
|
||||
const doGetImage = apiCallFactory('GET', {
|
||||
handleResponse: async res => URL.createObjectURL(await res.blob())
|
||||
})
|
||||
|
||||
const getImage = async ({ accessToken, refresh, signal, url }: GetImageDeps) =>
|
||||
doGetImage({
|
||||
accessToken,
|
||||
refresh,
|
||||
signal,
|
||||
url
|
||||
})
|
||||
if (response.ok) {
|
||||
return URL.createObjectURL(await response.blob())
|
||||
} else if (response.status === 401) {
|
||||
throw new ExpiredTokenError()
|
||||
} else {
|
||||
throw new ApiError(response.status, response.statusText)
|
||||
}
|
||||
}
|
||||
|
||||
export default getImage
|
||||
|
@ -1,33 +1,61 @@
|
||||
import { AuthContextType } from '../auth'
|
||||
import { ApiError } from './ApiError'
|
||||
import ExpiredTokenError from './ExpiredTokenError'
|
||||
import FullRecipeView, { RawFullRecipeView, toFullRecipeView } from './types/FullRecipeView'
|
||||
import GetRecipeView, { RawGetRecipeView, toGetRecipeView } from './types/GetRecipeView'
|
||||
import AccessToken from '../types/AccessToken'
|
||||
import Refresh from '../types/Refresh'
|
||||
import apiCallFactory from './apiCallFactory'
|
||||
import GetRecipeView, {
|
||||
GetRecipeViewWithRawText,
|
||||
toGetRecipeView,
|
||||
toGetRecipeViewWithRawText
|
||||
} from './types/GetRecipeView'
|
||||
|
||||
export interface GetRecipeDeps {
|
||||
authContext: AuthContextType
|
||||
username: string
|
||||
export interface GetRecipeCommonDeps {
|
||||
accessToken: AccessToken | null
|
||||
refresh: Refresh
|
||||
slug: string
|
||||
abortSignal: AbortSignal
|
||||
signal: AbortSignal
|
||||
username: string
|
||||
}
|
||||
|
||||
const getRecipe = async ({ authContext, username, slug, abortSignal }: GetRecipeDeps): Promise<GetRecipeView> => {
|
||||
const headers = new Headers()
|
||||
if (authContext.token !== null) {
|
||||
headers.set('Authorization', `Bearer ${authContext.token}`)
|
||||
}
|
||||
const response = await fetch(import.meta.env.VITE_MME_API_URL + `/recipes/${username}/${slug}`, {
|
||||
signal: abortSignal,
|
||||
headers,
|
||||
mode: 'cors'
|
||||
})
|
||||
if (response.ok) {
|
||||
return toGetRecipeView((await response.json()) as RawGetRecipeView)
|
||||
} else if (response.status === 401) {
|
||||
throw new ExpiredTokenError()
|
||||
} else {
|
||||
throw new ApiError(response.status, response.statusText)
|
||||
}
|
||||
export interface GetRecipeDeps extends GetRecipeCommonDeps {
|
||||
includeRawText?: false
|
||||
}
|
||||
|
||||
export interface GetRecipeDepsIncludeRawText extends GetRecipeCommonDeps {
|
||||
includeRawText: true
|
||||
}
|
||||
|
||||
export interface GetRecipe {
|
||||
(deps: GetRecipeDeps): Promise<GetRecipeView>
|
||||
(deps: GetRecipeDepsIncludeRawText): Promise<GetRecipeViewWithRawText>
|
||||
}
|
||||
|
||||
const doGetRecipe = apiCallFactory('GET', toGetRecipeView)
|
||||
const doGetRecipeIncludeRawText = apiCallFactory('GET', toGetRecipeViewWithRawText)
|
||||
|
||||
const getRecipe = (async ({
|
||||
accessToken,
|
||||
includeRawText,
|
||||
refresh,
|
||||
slug,
|
||||
signal,
|
||||
username
|
||||
}: GetRecipeDeps | GetRecipeDepsIncludeRawText): Promise<GetRecipeView | GetRecipeViewWithRawText> => {
|
||||
const endpoint = `/recipes/${username}/${slug}`
|
||||
if (includeRawText) {
|
||||
return doGetRecipeIncludeRawText({
|
||||
accessToken,
|
||||
endpoint,
|
||||
query: 'includeRawText=true',
|
||||
refresh,
|
||||
signal
|
||||
})
|
||||
} else {
|
||||
return doGetRecipe({
|
||||
accessToken,
|
||||
endpoint,
|
||||
refresh,
|
||||
signal
|
||||
})
|
||||
}
|
||||
}) as GetRecipe
|
||||
|
||||
export default getRecipe
|
||||
|
@ -1,70 +1,24 @@
|
||||
import { ApiError } from './ApiError'
|
||||
import ExpiredTokenError from './ExpiredTokenError'
|
||||
import { toImageView } from './types/ImageView'
|
||||
import RecipeInfosView, { RawRecipeInfosView } from './types/RecipeInfosView'
|
||||
import AccessToken from '../types/AccessToken'
|
||||
import Refresh from '../types/Refresh'
|
||||
import apiCallFactory from './apiCallFactory'
|
||||
import { toRecipeInfosView } from './types/RecipeInfosView'
|
||||
|
||||
export interface GetRecipeInfosDeps {
|
||||
abortSignal: AbortSignal
|
||||
token: string | null
|
||||
accessToken: AccessToken | null
|
||||
pageNumber: number
|
||||
pageSize: number
|
||||
refresh: Refresh
|
||||
signal: AbortSignal
|
||||
}
|
||||
|
||||
const getRecipeInfos = async ({
|
||||
abortSignal,
|
||||
token,
|
||||
pageNumber,
|
||||
pageSize
|
||||
}: GetRecipeInfosDeps): Promise<RecipeInfosView> => {
|
||||
const headers = new Headers()
|
||||
if (token !== null) {
|
||||
headers.set('Authorization', `Bearer ${token}`)
|
||||
}
|
||||
const response = await fetch(import.meta.env.VITE_MME_API_URL + `/recipes?page=${pageNumber}&size=${pageSize}`, {
|
||||
signal: abortSignal,
|
||||
headers,
|
||||
mode: 'cors'
|
||||
const doGetRecipeInfos = apiCallFactory('GET', toRecipeInfosView)
|
||||
|
||||
const getRecipeInfos = ({ accessToken, pageNumber, pageSize, refresh, signal }: GetRecipeInfosDeps) =>
|
||||
doGetRecipeInfos({
|
||||
accessToken,
|
||||
endpoint: `/recipes?page=${pageNumber}&size=${pageSize}`,
|
||||
refresh,
|
||||
signal
|
||||
})
|
||||
if (response.ok) {
|
||||
const { pageNumber, pageSize, content } = (await response.json()) as RawRecipeInfosView
|
||||
return {
|
||||
pageNumber,
|
||||
pageSize,
|
||||
content: content.map(
|
||||
({
|
||||
id,
|
||||
updated: rawUpdated,
|
||||
title,
|
||||
preparationTime,
|
||||
cookingTime,
|
||||
totalTime,
|
||||
ownerId,
|
||||
owner,
|
||||
isPublic,
|
||||
starCount,
|
||||
mainImage: rawMainImage,
|
||||
slug
|
||||
}) => ({
|
||||
id,
|
||||
updated: new Date(rawUpdated),
|
||||
title,
|
||||
preparationTime,
|
||||
cookingTime,
|
||||
totalTime,
|
||||
ownerId,
|
||||
owner,
|
||||
isPublic,
|
||||
starCount,
|
||||
mainImage: toImageView(rawMainImage),
|
||||
slug
|
||||
})
|
||||
)
|
||||
}
|
||||
} else if (response.status === 401) {
|
||||
throw new ExpiredTokenError()
|
||||
} else {
|
||||
throw new ApiError(response.status, response.statusText)
|
||||
}
|
||||
}
|
||||
|
||||
export default getRecipeInfos
|
||||
|
@ -1,25 +1,21 @@
|
||||
import { ApiError } from './ApiError'
|
||||
import ExpiredTokenError from './ExpiredTokenError'
|
||||
import AccessToken from '../types/AccessToken'
|
||||
import Refresh from '../types/Refresh'
|
||||
import apiCallFactory from './apiCallFactory'
|
||||
|
||||
export interface RemoveStarDeps {
|
||||
token: string
|
||||
accessToken: AccessToken
|
||||
refresh: Refresh
|
||||
username: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
const removeStar = async ({ token, username, slug }: RemoveStarDeps) => {
|
||||
const headers = new Headers()
|
||||
headers.set('Authorization', `Bearer ${token}`)
|
||||
const response = await fetch(import.meta.env.VITE_MME_API_URL + `/recipes/${username}/${slug}/star`, {
|
||||
headers,
|
||||
method: 'DELETE',
|
||||
mode: 'cors'
|
||||
const doRemoveStar = apiCallFactory<void>('DELETE')
|
||||
|
||||
const removeStar = ({ accessToken, refresh, username, slug }: RemoveStarDeps) =>
|
||||
doRemoveStar({
|
||||
accessToken,
|
||||
endpoint: `/recipes/${username}/${slug}/star`,
|
||||
refresh
|
||||
})
|
||||
if (response.status === 401) {
|
||||
throw new ExpiredTokenError()
|
||||
} else if (!response.ok) {
|
||||
throw new ApiError(response.status, response.statusText)
|
||||
}
|
||||
}
|
||||
|
||||
export default removeStar
|
||||
|
@ -7,34 +7,42 @@ export interface RawFullRecipeView {
|
||||
modified: string | null
|
||||
slug: string
|
||||
title: string
|
||||
preparationTime: number
|
||||
cookingTime: number
|
||||
totalTime: number
|
||||
preparationTime: number | null
|
||||
cookingTime: number | null
|
||||
totalTime: number | null
|
||||
text: string
|
||||
owner: UserInfoView
|
||||
starCount: number
|
||||
viewerCount: number
|
||||
mainImage: RawImageView
|
||||
mainImage: RawImageView | null
|
||||
isPublic: boolean
|
||||
}
|
||||
|
||||
export interface RawFullRecipeViewWithRawText extends RawFullRecipeView {
|
||||
rawText: string
|
||||
}
|
||||
|
||||
interface FullRecipeView {
|
||||
id: number
|
||||
created: Date
|
||||
modified: Date | null
|
||||
slug: string
|
||||
title: string
|
||||
preparationTime: number
|
||||
cookingTime: number
|
||||
totalTime: number
|
||||
preparationTime: number | null
|
||||
cookingTime: number | null
|
||||
totalTime: number | null
|
||||
text: string
|
||||
owner: UserInfoView
|
||||
starCount: number
|
||||
viewerCount: number
|
||||
mainImage: ImageView
|
||||
mainImage: ImageView | null
|
||||
isPublic: boolean
|
||||
}
|
||||
|
||||
export interface FullRecipeViewWithRawText extends FullRecipeView {
|
||||
rawText: string
|
||||
}
|
||||
|
||||
export const toFullRecipeView = ({
|
||||
id,
|
||||
created: rawCreated,
|
||||
@ -63,8 +71,13 @@ export const toFullRecipeView = ({
|
||||
owner,
|
||||
starCount,
|
||||
viewerCount,
|
||||
mainImage: toImageView(rawMainImage),
|
||||
mainImage: rawMainImage !== null ? toImageView(rawMainImage) : null,
|
||||
isPublic
|
||||
})
|
||||
|
||||
export const toFullRecipeViewWithRawText = (raw: RawFullRecipeViewWithRawText): FullRecipeViewWithRawText => ({
|
||||
rawText: raw.rawText,
|
||||
...toFullRecipeView(raw)
|
||||
})
|
||||
|
||||
export default FullRecipeView
|
||||
|
@ -1,4 +1,10 @@
|
||||
import FullRecipeView, { RawFullRecipeView, toFullRecipeView } from './FullRecipeView'
|
||||
import FullRecipeView, {
|
||||
FullRecipeViewWithRawText,
|
||||
RawFullRecipeView,
|
||||
RawFullRecipeViewWithRawText,
|
||||
toFullRecipeView,
|
||||
toFullRecipeViewWithRawText
|
||||
} from './FullRecipeView'
|
||||
|
||||
export interface RawGetRecipeView {
|
||||
recipe: RawFullRecipeView
|
||||
@ -6,16 +12,34 @@ export interface RawGetRecipeView {
|
||||
isOwner: boolean | null
|
||||
}
|
||||
|
||||
export interface RawGetRecipeViewWithRawText extends RawGetRecipeView {
|
||||
recipe: RawFullRecipeViewWithRawText
|
||||
}
|
||||
|
||||
interface GetRecipeView {
|
||||
recipe: FullRecipeView
|
||||
isStarred: boolean | null
|
||||
isOwner: boolean | null
|
||||
}
|
||||
|
||||
export interface GetRecipeViewWithRawText extends GetRecipeView {
|
||||
recipe: FullRecipeViewWithRawText
|
||||
}
|
||||
|
||||
export const toGetRecipeView = ({ recipe, isStarred, isOwner }: RawGetRecipeView): GetRecipeView => ({
|
||||
recipe: toFullRecipeView(recipe),
|
||||
isStarred,
|
||||
isOwner
|
||||
})
|
||||
|
||||
export const toGetRecipeViewWithRawText = ({
|
||||
recipe,
|
||||
isStarred,
|
||||
isOwner
|
||||
}: RawGetRecipeViewWithRawText): GetRecipeViewWithRawText => ({
|
||||
recipe: toFullRecipeViewWithRawText(recipe),
|
||||
isStarred,
|
||||
isOwner
|
||||
})
|
||||
|
||||
export default GetRecipeView
|
||||
|
@ -1,24 +1,25 @@
|
||||
import ImageView, { RawImageView } from './ImageView'
|
||||
import ImageView, { RawImageView, toImageView } from './ImageView'
|
||||
import UserInfoView from './UserInfoView'
|
||||
|
||||
export interface RawRecipeInfoView {
|
||||
id: number
|
||||
updated: string
|
||||
created: string
|
||||
modified: string | null
|
||||
title: string
|
||||
preparationTime: number
|
||||
cookingTime: number
|
||||
totalTime: number
|
||||
ownerId: number
|
||||
owner: UserInfoView
|
||||
isPublic: boolean
|
||||
starCount: number
|
||||
mainImage: RawImageView
|
||||
mainImage: RawImageView | null
|
||||
slug: string
|
||||
}
|
||||
|
||||
interface RecipeInfoView {
|
||||
id: number
|
||||
updated: Date
|
||||
created: Date
|
||||
modified: Date | null
|
||||
title: string
|
||||
preparationTime: number
|
||||
cookingTime: number
|
||||
@ -26,8 +27,36 @@ interface RecipeInfoView {
|
||||
owner: UserInfoView
|
||||
isPublic: boolean
|
||||
starCount: number
|
||||
mainImage: ImageView
|
||||
mainImage: ImageView | null
|
||||
slug: string
|
||||
}
|
||||
|
||||
export const toRecipeInfoView = ({
|
||||
id,
|
||||
created: rawCreated,
|
||||
modified: rawModified,
|
||||
title,
|
||||
preparationTime,
|
||||
cookingTime,
|
||||
totalTime,
|
||||
owner,
|
||||
isPublic,
|
||||
starCount,
|
||||
mainImage: rawMainImage,
|
||||
slug
|
||||
}: RawRecipeInfoView): RecipeInfoView => ({
|
||||
id,
|
||||
created: new Date(rawCreated),
|
||||
modified: rawModified !== null ? new Date(rawModified) : null,
|
||||
title,
|
||||
preparationTime,
|
||||
cookingTime,
|
||||
totalTime,
|
||||
owner,
|
||||
isPublic,
|
||||
starCount,
|
||||
mainImage: rawMainImage !== null ? toImageView(rawMainImage) : null,
|
||||
slug
|
||||
})
|
||||
|
||||
export default RecipeInfoView
|
||||
|
@ -1,4 +1,4 @@
|
||||
import RecipeInfoView, { RawRecipeInfoView } from './RecipeInfoView'
|
||||
import RecipeInfoView, { RawRecipeInfoView, toRecipeInfoView } from './RecipeInfoView'
|
||||
|
||||
export interface RawRecipeInfosView {
|
||||
pageNumber: number
|
||||
@ -12,4 +12,10 @@ interface RecipeInfosView {
|
||||
content: RecipeInfoView[]
|
||||
}
|
||||
|
||||
export const toRecipeInfosView = ({ pageNumber, pageSize, content }: RawRecipeInfosView): RecipeInfosView => ({
|
||||
pageNumber,
|
||||
pageSize,
|
||||
content: content.map(toRecipeInfoView)
|
||||
})
|
||||
|
||||
export default RecipeInfosView
|
||||
|
42
src/api/types/UpdateRecipeSpec.ts
Normal file
42
src/api/types/UpdateRecipeSpec.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { FullRecipeViewWithRawText } from './FullRecipeView'
|
||||
|
||||
export interface MainImageUpdateSpec {
|
||||
username: string
|
||||
filename: string
|
||||
}
|
||||
|
||||
interface UpdateRecipeSpec {
|
||||
title: string
|
||||
preparationTime: number | null
|
||||
cookingTime: number | null
|
||||
totalTime: number | null
|
||||
rawText: string
|
||||
isPublic: boolean
|
||||
mainImage: MainImageUpdateSpec | null
|
||||
}
|
||||
|
||||
export const fromFullRecipeView = ({
|
||||
title,
|
||||
preparationTime,
|
||||
cookingTime,
|
||||
totalTime,
|
||||
rawText,
|
||||
isPublic,
|
||||
mainImage
|
||||
}: FullRecipeViewWithRawText): UpdateRecipeSpec => ({
|
||||
title,
|
||||
preparationTime,
|
||||
cookingTime,
|
||||
totalTime,
|
||||
rawText,
|
||||
isPublic,
|
||||
mainImage:
|
||||
mainImage !== null
|
||||
? {
|
||||
username: mainImage.owner.username,
|
||||
filename: mainImage.filename
|
||||
}
|
||||
: null
|
||||
})
|
||||
|
||||
export default UpdateRecipeSpec
|
31
src/api/updateRecipe.ts
Normal file
31
src/api/updateRecipe.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import AccessToken from '../types/AccessToken'
|
||||
import Refresh from '../types/Refresh'
|
||||
import apiCallFactory from './apiCallFactory'
|
||||
import { GetRecipeViewWithRawText, toGetRecipeViewWithRawText } from './types/GetRecipeView'
|
||||
import UpdateRecipeSpec from './types/UpdateRecipeSpec'
|
||||
|
||||
export interface UpdateRecipeDeps {
|
||||
spec: UpdateRecipeSpec
|
||||
accessToken: AccessToken
|
||||
refresh: Refresh
|
||||
username: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
const doUpdateRecipe = apiCallFactory('POST', toGetRecipeViewWithRawText)
|
||||
|
||||
const updateRecipe = ({
|
||||
spec,
|
||||
accessToken,
|
||||
refresh,
|
||||
username,
|
||||
slug
|
||||
}: UpdateRecipeDeps): Promise<GetRecipeViewWithRawText> =>
|
||||
doUpdateRecipe({
|
||||
accessToken,
|
||||
body: spec,
|
||||
endpoint: `/recipes/${username}/${slug}`,
|
||||
refresh
|
||||
})
|
||||
|
||||
export default updateRecipe
|
5
src/api/util.ts
Normal file
5
src/api/util.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import AccessToken from '../types/AccessToken'
|
||||
|
||||
export const addBearer = (headers: Headers, accessToken: AccessToken) => {
|
||||
headers.set('Authorization', `Bearer ${accessToken.token}`)
|
||||
}
|
67
src/auth.tsx
67
src/auth.tsx
@ -1,67 +0,0 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react'
|
||||
|
||||
export interface AuthContextType {
|
||||
token: string | null
|
||||
username: string | null
|
||||
putToken(token: string, username: string, cb?: () => void): void
|
||||
clearToken(cb?: () => void): void
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
token: string | null
|
||||
username: string | null
|
||||
putCb?: () => void
|
||||
clearCb?: () => void
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null)
|
||||
|
||||
export const AuthProvider = ({ children }: React.PropsWithChildren) => {
|
||||
const [authState, setAuthState] = useState<AuthState>({
|
||||
token: null,
|
||||
username: null
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (authState.token === null && authState.clearCb !== undefined) {
|
||||
authState.clearCb()
|
||||
setAuthState({ ...authState, clearCb: undefined })
|
||||
} else if (authState.token !== null && authState.putCb !== undefined) {
|
||||
authState.putCb()
|
||||
setAuthState({ ...authState, putCb: undefined })
|
||||
}
|
||||
}, [authState.token])
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
token: authState.token,
|
||||
username: authState.username,
|
||||
putToken(token, username, cb) {
|
||||
setAuthState({
|
||||
token,
|
||||
username,
|
||||
putCb: cb
|
||||
})
|
||||
},
|
||||
clearToken(cb) {
|
||||
setAuthState({
|
||||
token: null,
|
||||
username: null,
|
||||
clearCb: cb
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useAuth = () => {
|
||||
const auth = useContext(AuthContext)
|
||||
if (!auth) {
|
||||
throw new Error('useAuth must be used in an AuthProvider context')
|
||||
}
|
||||
return auth
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import { useLocation, useNavigate, useRouter } from '@tanstack/react-router'
|
||||
import { useAuth } from '../../auth'
|
||||
import { useLocation, useNavigate } from '@tanstack/react-router'
|
||||
import { useAuth } from '../../AuthProvider'
|
||||
import classes from './header.module.css'
|
||||
|
||||
export interface HeaderProps {
|
||||
@ -7,20 +7,17 @@ export interface HeaderProps {
|
||||
}
|
||||
|
||||
const Header = ({ username }: HeaderProps) => {
|
||||
const auth = useAuth()
|
||||
const router = useRouter()
|
||||
const { putToken } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
const onLogin = async () => {
|
||||
const onLogin = () => {
|
||||
navigate({ to: '/login', search: { redirect: location.href } })
|
||||
}
|
||||
|
||||
const onLogout = async () => {
|
||||
auth.clearToken(async () => {
|
||||
await router.invalidate()
|
||||
await navigate({ to: '/login' })
|
||||
})
|
||||
const onLogout = () => {
|
||||
putToken(null)
|
||||
navigate({ to: '/login' })
|
||||
}
|
||||
|
||||
return (
|
||||
|
38
src/main.tsx
38
src/main.tsx
@ -1,12 +1,16 @@
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
import { fas } from '@fortawesome/free-solid-svg-icons'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import { Router, RouterProvider, createRouter } from '@tanstack/react-router'
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { AuthProvider, useAuth } from './auth'
|
||||
import AuthAwareQueryClientProvider from './AuthAwareQueryClientProvider'
|
||||
import { ApiError } from './api/ApiError'
|
||||
import ExpiredTokenError from './api/ExpiredTokenError'
|
||||
import { AuthProvider, useAuth } from './AuthProvider'
|
||||
import './main.css'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
import RefreshProvider from './RefreshProvider'
|
||||
|
||||
// Font-Awesome: load icons
|
||||
library.add(fas)
|
||||
@ -27,13 +31,41 @@ declare module '@tanstack/react-router' {
|
||||
}
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry(failureCount, error) {
|
||||
if (error instanceof ExpiredTokenError || (error instanceof ApiError && error.status === 404)) {
|
||||
return false
|
||||
} else {
|
||||
return failureCount <= 3
|
||||
}
|
||||
},
|
||||
retryDelay(failureCount, error) {
|
||||
if (error instanceof ExpiredTokenError) {
|
||||
return 0
|
||||
} else {
|
||||
return failureCount * 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const InnerApp = () => {
|
||||
const auth = useAuth()
|
||||
return (
|
||||
<RouterProvider
|
||||
router={router}
|
||||
context={{ auth }}
|
||||
InnerWrap={({ children }) => <AuthAwareQueryClientProvider>{children}</AuthAwareQueryClientProvider>}
|
||||
InnerWrap={({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RefreshProvider>
|
||||
{children}
|
||||
<ReactQueryDevtools position="right" buttonPosition="top-right" />
|
||||
</RefreshProvider>
|
||||
</QueryClientProvider>
|
||||
)}
|
||||
></RouterProvider>
|
||||
)
|
||||
}
|
||||
|
236
src/pages/edit-recipe/EditRecipe.tsx
Normal file
236
src/pages/edit-recipe/EditRecipe.tsx
Normal file
@ -0,0 +1,236 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
ChangeEventHandler,
|
||||
FormEventHandler,
|
||||
PropsWithChildren,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import { ApiError } from '../../api/ApiError'
|
||||
import getRecipe from '../../api/getRecipe'
|
||||
import { FullRecipeViewWithRawText } from '../../api/types/FullRecipeView'
|
||||
import UpdateRecipeSpec, { MainImageUpdateSpec } from '../../api/types/UpdateRecipeSpec'
|
||||
import updateRecipe from '../../api/updateRecipe'
|
||||
import { useAuth } from '../../AuthProvider'
|
||||
import { useRefresh } from '../../RefreshProvider'
|
||||
import classes from './edit-recipe.module.css'
|
||||
|
||||
interface ControlProps {
|
||||
id: string
|
||||
displayName: string
|
||||
}
|
||||
|
||||
const Control = ({ id, displayName, children }: PropsWithChildren<ControlProps>) => {
|
||||
return (
|
||||
<div className={classes.control}>
|
||||
<label htmlFor={id}>{displayName}</label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const getOnTimeChange =
|
||||
(setTime: (n: number | null) => void): ChangeEventHandler<HTMLInputElement> =>
|
||||
e => {
|
||||
if (e.target.value === '') {
|
||||
setTime(null)
|
||||
} else {
|
||||
const parsed = parseInt(e.target.value)
|
||||
if (!Number.isNaN(parsed)) {
|
||||
setTime(parsed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface EditRecipeProps {
|
||||
username: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
const EditRecipe = ({ username, slug }: EditRecipeProps) => {
|
||||
const { accessToken } = useAuth()
|
||||
const refresh = useRefresh()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const titleRef = useRef<HTMLInputElement | null>(null)
|
||||
const [preparationTime, setPreparationTime] = useState<number | null>(null)
|
||||
const [cookingTime, setCookingTime] = useState<number | null>(null)
|
||||
const [totalTime, setTotalTime] = useState<number | null>(null)
|
||||
const recipeTextRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
const isPublicRef = useRef<HTMLInputElement | null>(null)
|
||||
const [mainImage, setMainImage] = useState<MainImageUpdateSpec | null>(null)
|
||||
|
||||
const onPreparationTimeChange = useCallback(getOnTimeChange(setPreparationTime), [setPreparationTime])
|
||||
const onCookingTimeChange = useCallback(getOnTimeChange(setCookingTime), [setCookingTime])
|
||||
const onTotalTimeChange = useCallback(getOnTimeChange(setTotalTime), [setTotalTime])
|
||||
|
||||
const setState = useCallback(
|
||||
(recipe: FullRecipeViewWithRawText) => {
|
||||
if (titleRef.current) {
|
||||
titleRef.current.value = recipe.title
|
||||
}
|
||||
setPreparationTime(recipe.preparationTime)
|
||||
setCookingTime(recipe.cookingTime)
|
||||
setTotalTime(recipe.totalTime)
|
||||
if (recipeTextRef.current) {
|
||||
recipeTextRef.current.value = recipe.rawText
|
||||
}
|
||||
if (isPublicRef.current) {
|
||||
isPublicRef.current.checked = recipe.isPublic
|
||||
}
|
||||
setMainImage(
|
||||
recipe.mainImage
|
||||
? {
|
||||
username: recipe.mainImage.owner.username,
|
||||
filename: recipe.mainImage?.filename
|
||||
}
|
||||
: null
|
||||
)
|
||||
},
|
||||
[setPreparationTime, setCookingTime, setTotalTime, setMainImage]
|
||||
)
|
||||
|
||||
const recipeQuery = useQuery(
|
||||
{
|
||||
queryKey: ['recipes', username, slug],
|
||||
queryFn: ({ signal }) =>
|
||||
getRecipe({
|
||||
accessToken,
|
||||
includeRawText: true,
|
||||
refresh,
|
||||
slug,
|
||||
signal,
|
||||
username
|
||||
})
|
||||
},
|
||||
queryClient
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (recipeQuery.isSuccess) {
|
||||
setState(recipeQuery.data.recipe)
|
||||
}
|
||||
}, [recipeQuery.isSuccess, setState, recipeQuery.data?.recipe])
|
||||
|
||||
const mutation = useMutation(
|
||||
{
|
||||
mutationFn: (variables: { spec: UpdateRecipeSpec }) => {
|
||||
if (accessToken !== null) {
|
||||
return updateRecipe({
|
||||
spec: variables.spec,
|
||||
accessToken,
|
||||
refresh,
|
||||
username,
|
||||
slug
|
||||
})
|
||||
} else {
|
||||
return Promise.reject('Must be logged in.')
|
||||
}
|
||||
},
|
||||
onSuccess: data => {
|
||||
setState(data.recipe)
|
||||
queryClient.setQueryData(['recipes', username, slug], data)
|
||||
}
|
||||
},
|
||||
queryClient
|
||||
)
|
||||
|
||||
const onSubmit: FormEventHandler<HTMLFormElement> = useCallback(
|
||||
e => {
|
||||
e.preventDefault()
|
||||
mutation.mutate({
|
||||
spec: {
|
||||
title: titleRef.current!.value,
|
||||
preparationTime,
|
||||
cookingTime,
|
||||
totalTime,
|
||||
rawText: recipeTextRef.current!.value,
|
||||
isPublic: isPublicRef.current!.checked,
|
||||
mainImage
|
||||
}
|
||||
})
|
||||
return false
|
||||
},
|
||||
[mutation]
|
||||
)
|
||||
|
||||
if (recipeQuery.isPending) {
|
||||
return 'Loading...'
|
||||
} else if (recipeQuery.isError) {
|
||||
const { error } = recipeQuery
|
||||
if (error instanceof ApiError) {
|
||||
if (error.status === 404) {
|
||||
return 'No such recipe.'
|
||||
} else {
|
||||
return `ApiError: ${error.status} ${error.message}`
|
||||
}
|
||||
} else {
|
||||
return `Error: ${error.name} ${error.message}`
|
||||
}
|
||||
} else if (recipeQuery.isSuccess && !recipeQuery.data.isOwner) {
|
||||
return 'You do not have permission to edit this recipe.'
|
||||
} else {
|
||||
return (
|
||||
<div className={classes.articleContainer}>
|
||||
<article>
|
||||
<h1>Edit Recipe</h1>
|
||||
<form className={classes.editForm} onSubmit={onSubmit}>
|
||||
<Control id="title" displayName="Title">
|
||||
<input id="title" type="text" ref={titleRef} />
|
||||
</Control>
|
||||
|
||||
<Control id="preparation-time" displayName="Preparation Time (in minutes)">
|
||||
<input
|
||||
id="preparation-time"
|
||||
type="text"
|
||||
value={preparationTime?.toString() ?? ''}
|
||||
onChange={onPreparationTimeChange}
|
||||
/>
|
||||
</Control>
|
||||
|
||||
<Control id="cooking-time" displayName="Cooking Time (in minutes)">
|
||||
<input
|
||||
id="cooking-time"
|
||||
type="text"
|
||||
value={cookingTime?.toString() ?? ''}
|
||||
onChange={onCookingTimeChange}
|
||||
/>
|
||||
</Control>
|
||||
|
||||
<Control id="total-time" displayName="Total Time (in minutes)">
|
||||
<input
|
||||
id="total-time"
|
||||
type="text"
|
||||
value={totalTime?.toString() ?? ''}
|
||||
onChange={onTotalTimeChange}
|
||||
/>
|
||||
</Control>
|
||||
|
||||
<Control id="recipe-text" displayName="Recipe Text">
|
||||
<textarea id="recipe-text" ref={recipeTextRef} />
|
||||
</Control>
|
||||
|
||||
<Control id="is-public" displayName="Is Public?">
|
||||
<input id="is-public" type="checkbox" ref={isPublicRef} />
|
||||
</Control>
|
||||
|
||||
<div className={classes.submitContainer}>
|
||||
<input type="submit" />
|
||||
{mutation.isPending
|
||||
? 'Saving...'
|
||||
: mutation.isSuccess
|
||||
? 'Saved!'
|
||||
: mutation.isError
|
||||
? `Error! ${mutation.error}`
|
||||
: null}
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default EditRecipe
|
14
src/pages/edit-recipe/edit-recipe.module.css
Normal file
14
src/pages/edit-recipe/edit-recipe.module.css
Normal file
@ -0,0 +1,14 @@
|
||||
.article-container {
|
||||
}
|
||||
|
||||
.edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 10px;
|
||||
}
|
||||
|
||||
.control {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
column-gap: 5px;
|
||||
}
|
@ -1,16 +1,17 @@
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { QueryObserverSuccessResult, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import addStar from '../../api/addStar'
|
||||
import { ApiError } from '../../api/ApiError'
|
||||
import getImage from '../../api/getImage'
|
||||
import getRecipe from '../../api/getRecipe'
|
||||
import removeStar from '../../api/removeStar'
|
||||
import GetRecipeView from '../../api/types/GetRecipeView'
|
||||
import { useAuth } from '../../auth'
|
||||
import { useAuth } from '../../AuthProvider'
|
||||
import RecipeVisibilityIcon from '../../components/recipe-visibility-icon/RecipeVisibilityIcon'
|
||||
import UserIconAndName from '../../components/user-icon-and-name/UserIconAndName'
|
||||
import { useRefresh } from '../../RefreshProvider'
|
||||
import classes from './recipe.module.css'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
|
||||
interface EditButtonProps {
|
||||
username: string
|
||||
@ -51,16 +52,18 @@ interface RecipeStarButtonProps {
|
||||
}
|
||||
|
||||
const RecipeStarButton = ({ username, slug, isStarred, starCount }: RecipeStarButtonProps) => {
|
||||
const authContext = useAuth()
|
||||
const { accessToken } = useAuth()
|
||||
const queryClient = useQueryClient()
|
||||
const refresh = useRefresh()
|
||||
|
||||
const addStarMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (authContext.token !== null) {
|
||||
if (accessToken !== null) {
|
||||
return addStar({
|
||||
token: authContext.token,
|
||||
slug,
|
||||
username
|
||||
accessToken,
|
||||
refresh,
|
||||
username,
|
||||
slug
|
||||
})
|
||||
} else {
|
||||
return Promise.resolve()
|
||||
@ -73,9 +76,10 @@ const RecipeStarButton = ({ username, slug, isStarred, starCount }: RecipeStarBu
|
||||
|
||||
const removeStarMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (authContext.token !== null) {
|
||||
if (accessToken !== null) {
|
||||
return removeStar({
|
||||
token: authContext.token,
|
||||
accessToken,
|
||||
refresh,
|
||||
slug,
|
||||
username
|
||||
})
|
||||
@ -111,18 +115,20 @@ export interface RecipeProps {
|
||||
}
|
||||
|
||||
const Recipe = ({ username, slug }: RecipeProps) => {
|
||||
const authContext = useAuth()
|
||||
const { accessToken } = useAuth()
|
||||
const queryClient = useQueryClient()
|
||||
const refresh = useRefresh()
|
||||
|
||||
const recipeQuery = useQuery(
|
||||
{
|
||||
queryKey: ['recipes', username, slug],
|
||||
queryFn: ({ signal: abortSignal }) =>
|
||||
queryFn: ({ signal }) =>
|
||||
getRecipe({
|
||||
abortSignal,
|
||||
authContext,
|
||||
username,
|
||||
slug
|
||||
accessToken,
|
||||
refresh,
|
||||
signal,
|
||||
slug,
|
||||
username
|
||||
})
|
||||
},
|
||||
queryClient
|
||||
@ -130,17 +136,18 @@ const Recipe = ({ username, slug }: RecipeProps) => {
|
||||
|
||||
const mainImageQuery = useQuery(
|
||||
{
|
||||
enabled: recipeQuery.isSuccess,
|
||||
enabled: recipeQuery.isSuccess && recipeQuery.data!.recipe.mainImage !== null,
|
||||
queryKey: [
|
||||
'images',
|
||||
recipeQuery.data?.recipe.mainImage.owner.username,
|
||||
recipeQuery.data?.recipe.mainImage.filename
|
||||
recipeQuery.data?.recipe.mainImage?.owner.username,
|
||||
recipeQuery.data?.recipe.mainImage?.filename
|
||||
],
|
||||
queryFn: ({ signal }) =>
|
||||
getImage({
|
||||
accessToken: authContext.token,
|
||||
accessToken,
|
||||
signal,
|
||||
url: recipeQuery.data!.recipe.mainImage.url
|
||||
refresh,
|
||||
url: recipeQuery.data!.recipe.mainImage!.url
|
||||
})
|
||||
},
|
||||
queryClient
|
||||
|
@ -3,15 +3,17 @@ import { useState } from 'react'
|
||||
import { ApiError } from '../../api/ApiError'
|
||||
import getImage from '../../api/getImage'
|
||||
import getRecipeInfos from '../../api/getRecipeInfos'
|
||||
import { useAuth } from '../../auth'
|
||||
import { useAuth } from '../../AuthProvider'
|
||||
import RecipeCard from '../../components/recipe-card/RecipeCard'
|
||||
import classes from './recipes.module.css'
|
||||
import { useRefresh } from '../../RefreshProvider'
|
||||
|
||||
const Recipes = () => {
|
||||
const [pageNumber, setPageNumber] = useState(0)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
|
||||
const { token } = useAuth()
|
||||
const { accessToken } = useAuth()
|
||||
const refresh = useRefresh()
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const { data, isPending, error } = useQuery(
|
||||
@ -19,10 +21,11 @@ const Recipes = () => {
|
||||
queryKey: ['recipeInfos'],
|
||||
queryFn: ({ signal }) =>
|
||||
getRecipeInfos({
|
||||
abortSignal: signal,
|
||||
accessToken,
|
||||
pageNumber,
|
||||
pageSize,
|
||||
token
|
||||
refresh,
|
||||
signal
|
||||
})
|
||||
},
|
||||
queryClient
|
||||
@ -33,17 +36,19 @@ const Recipes = () => {
|
||||
data !== undefined
|
||||
? data.content.map(recipeInfoView => {
|
||||
return {
|
||||
enabled: recipeInfoView.mainImage !== null,
|
||||
queryKey: [
|
||||
'images',
|
||||
recipeInfoView.mainImage.owner.username,
|
||||
recipeInfoView.mainImage.filename
|
||||
recipeInfoView.mainImage?.owner.username,
|
||||
recipeInfoView.mainImage?.filename
|
||||
],
|
||||
queryFn: async ({ signal }: any) => {
|
||||
// any needed in the params
|
||||
const imgUrl = await getImage({
|
||||
accessToken: token,
|
||||
accessToken,
|
||||
refresh,
|
||||
signal,
|
||||
url: recipeInfoView.mainImage.url
|
||||
url: recipeInfoView.mainImage!.url
|
||||
})
|
||||
return {
|
||||
slug: recipeInfoView.slug,
|
||||
@ -83,7 +88,7 @@ const Recipes = () => {
|
||||
return slugAndImgUrl !== undefined && slugAndImgUrl.slug === view.slug
|
||||
})?.data!.imgUrl ?? '' // hacky workaround. should pass a kind of <Image> child which loads its own data
|
||||
}
|
||||
mainImageAlt={view.mainImage.alt ? view.mainImage.alt : undefined}
|
||||
mainImageAlt={view.mainImage?.alt ? view.mainImage.alt : undefined}
|
||||
starCount={view.starCount}
|
||||
isPublic={view.isPublic}
|
||||
/>
|
||||
|
@ -1,18 +1,18 @@
|
||||
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'
|
||||
import { TanStackRouterDevtools } from '@tanstack/router-devtools'
|
||||
import RouterContext from '../RouterContext'
|
||||
import { useAuth } from '../auth'
|
||||
import { useAuth } from '../AuthProvider'
|
||||
import Footer from '../components/footer/Footer'
|
||||
import Header from '../components/header/Header'
|
||||
import classes from './__root.module.css'
|
||||
import MainNav from '../components/main-nav/MainNav'
|
||||
|
||||
const RootLayout = () => {
|
||||
const { username } = useAuth()
|
||||
const { accessToken } = useAuth()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header username={username ?? undefined} />
|
||||
<Header username={accessToken?.username} />
|
||||
<div className={classes.mainWrapper}>
|
||||
<MainNav />
|
||||
<main>
|
||||
|
@ -2,7 +2,7 @@ import { Outlet, createFileRoute, redirect } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/_auth')({
|
||||
beforeLoad({ context, location }) {
|
||||
if (!context.auth.token) {
|
||||
if (!context.auth.accessToken) {
|
||||
throw redirect({
|
||||
to: '/login',
|
||||
search: {
|
||||
|
@ -1,14 +1,12 @@
|
||||
import { createFileRoute, redirect, useNavigate, useRouter, useSearch } from '@tanstack/react-router'
|
||||
import { createFileRoute, useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import { FormEvent, useState } from 'react'
|
||||
import { z } from 'zod'
|
||||
import login from '../api/login'
|
||||
import { useAuth } from '../auth'
|
||||
import { useAuth } from '../AuthProvider'
|
||||
|
||||
const Login = () => {
|
||||
const auth = useAuth()
|
||||
const { putToken } = useAuth()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const router = useRouter()
|
||||
const navigate = useNavigate()
|
||||
const { redirect, reason } = useSearch({ from: '/login' })
|
||||
|
||||
@ -19,12 +17,15 @@ const Login = () => {
|
||||
const password = (formData.get('password') as string | null) ?? ''
|
||||
const loginResult = await login(username, password)
|
||||
if (loginResult._tag === 'success') {
|
||||
auth.putToken(loginResult.loginView.accessToken, loginResult.loginView.username, async () => {
|
||||
await router.invalidate()
|
||||
await navigate({
|
||||
to: redirect ?? '/recipes',
|
||||
search: {}
|
||||
})
|
||||
const { accessToken: token, expires, username } = loginResult.loginView
|
||||
putToken({
|
||||
token,
|
||||
expires,
|
||||
username
|
||||
})
|
||||
navigate({
|
||||
to: redirect ?? '/recipes',
|
||||
search: {}
|
||||
})
|
||||
} else {
|
||||
setError(loginResult.error)
|
||||
@ -61,13 +62,16 @@ const Login = () => {
|
||||
|
||||
export const Route = createFileRoute('/login')({
|
||||
validateSearch: z.object({
|
||||
reason: z.enum(['INVALID_REFRESH_TOKEN', 'EXPIRED_REFRESH_TOKEN', 'NO_REFRESH_TOKEN']).optional(),
|
||||
reason: z
|
||||
.enum([
|
||||
'INVALID_REFRESH_TOKEN',
|
||||
'EXPIRED_REFRESH_TOKEN',
|
||||
'NO_REFRESH_TOKEN',
|
||||
'NOT_LOGGED_IN',
|
||||
'EXPIRED_ACCESS_TOKEN'
|
||||
])
|
||||
.optional(),
|
||||
redirect: z.string().optional().catch('')
|
||||
}),
|
||||
beforeLoad({ context, search }) {
|
||||
if (search.reason === undefined && context.auth.token !== null) {
|
||||
throw redirect({ to: '/recipes' })
|
||||
}
|
||||
},
|
||||
component: Login
|
||||
})
|
||||
|
@ -1,5 +1,9 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { createFileRoute, useParams } from '@tanstack/react-router'
|
||||
import EditRecipe from '../../../pages/edit-recipe/EditRecipe'
|
||||
|
||||
export const Route = createFileRoute('/recipes/$username/$slug/edit')({
|
||||
component: () => <div>Hello /recipes/$username/$slug/edit!</div>
|
||||
component: () => {
|
||||
const { username, slug } = useParams({ from: '/recipes/$username/$slug/edit' })
|
||||
return <EditRecipe {...{ username, slug }} />
|
||||
}
|
||||
})
|
||||
|
7
src/types/AccessToken.ts
Normal file
7
src/types/AccessToken.ts
Normal file
@ -0,0 +1,7 @@
|
||||
interface AccessToken {
|
||||
token: string
|
||||
username: string
|
||||
expires: Date
|
||||
}
|
||||
|
||||
export default AccessToken
|
7
src/types/Refresh.ts
Normal file
7
src/types/Refresh.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import AccessToken from './AccessToken'
|
||||
|
||||
interface Refresh {
|
||||
(): Promise<AccessToken | null>
|
||||
}
|
||||
|
||||
export default Refresh
|
Loading…
Reference in New Issue
Block a user