Compare commits

...

10 Commits

Author SHA1 Message Date
Jesse Brault
d6629e5176 EditRecipe using state and refs to track form data. 2024-08-24 18:43:35 -05:00
Jesse Brault
21c154ae47 Simplified EditRecipe page but still cannot figure out re-render bug. 2024-08-22 13:35:47 -05:00
Jesse Brault
5d1def13db Refactored AuthProvider and RefreshProvider to be simpler. 2024-08-22 13:32:41 -05:00
Jesse Brault
a3376a8cc1 Updated all api calls to use new apiCallFactory. 2024-08-22 08:00:10 -05:00
Jesse Brault
c54d3832a3 Major refactor of auth, refresh, and api calls. 2024-08-21 10:20:12 -05:00
Jesse Brault
c099275b31 doRefresh now only depends on router. 2024-08-20 11:11:45 -05:00
Jesse Brault
9cc05d0a7a Major refactor of auth and login logic. 2024-08-19 21:21:06 -05:00
Jesse Brault
af683d9ee9 RecipeEdit page basically working. 2024-08-17 11:16:15 -05:00
Jesse Brault
56dffa2ee8 Now using rawText from recipe api. 2024-08-15 14:19:22 -05:00
Jesse Brault
8ce916731f Basic recipe editing page. 2024-08-15 12:15:12 -05:00
30 changed files with 871 additions and 386 deletions

View File

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

View File

@ -1,4 +1,4 @@
import { AuthContextType } from './auth'
import { AuthContextType } from './AuthProvider'
export default interface RouterContext {
auth: AuthContextType

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

@ -0,0 +1,5 @@
import AccessToken from '../types/AccessToken'
export const addBearer = (headers: Headers, accessToken: AccessToken) => {
headers.set('Authorization', `Bearer ${accessToken.token}`)
}

View File

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

View File

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

View File

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

View 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

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

View File

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

View File

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

View File

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

View File

@ -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: {

View File

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

View File

@ -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
View File

@ -0,0 +1,7 @@
interface AccessToken {
token: string
username: string
expires: Date
}
export default AccessToken

7
src/types/Refresh.ts Normal file
View File

@ -0,0 +1,7 @@
import AccessToken from './AccessToken'
interface Refresh {
(): Promise<AccessToken | null>
}
export default Refresh