昨天完成了 api
內容,今天繼續來補齊前端部分~
開始之前先補一個昨天忘記說到的內容,首先我們簡單測試一下 api
,這邊要注意下 trpc
的 body
是要 {0:{json:{}}}
格式去寫跟 restful
直接寫 fields
不一樣。
但這邊會預報一個問題是,假設 req.body
內容不完成,此時看 errors
很難看出來問題在哪邊。
這邊我們可以在 initTRPC
時加 errorFormatter
export const t = initTRPC.context<Context>().create({
transformer: SuperJSON,
errorFormatter: (opts) => {
const { shape, error } = opts
return {
...shape,
data: {
...shape.data,
zodError:
error.code === 'BAD_REQUEST' && error.cause instanceof ZodError
? error.cause.flatten()
: null
}
}
}
})
如此一來 erros
的內容就清楚多了~
前端狀態管理部分打算採用 zustand
,zustand
的好處是可以非常簡單上手,別於 redux
來說不需要做太多的設定,非常適合用來管理簡單的狀態,zustand
跟 redux
一樣有 store
概念,如果想了解更多可以看筆者之前練習的介紹~。
> npm install zustand
export type IUser = {
_id: string;
id: string;
email: string;
name: string;
role: string;
photo: string;
updatedAt: string;
createdAt: string;
};
在 zustand
中如果要做持久化數據可以透過 middleware
寫法,如底下的 persist function
一層一層包住 create function
的內容,透過 persist functions
你可以指定 storage name
跟 storage type
。
import { IUser } from './../lib/types';
import { create } from "zustand";
import { persist, createJSONStorage } from 'zustand/middleware'
type Store = {
authUser: IUser | null
access_token: string | null
uploadImage: boolean
pageLoading: boolean
setAuthUser: (user: IUser) => void
setAccessToken: (token: string) => void
deleteAccessToken: () => void
setUPloadImage: (isUploading: boolean) => void
setPageLoading: (isLoading: boolean) => void
}
export const useStore = create<Store>()(
persist(
(set) => ({
authUser: null,
access_token: null,
uploadImage: false,
pageLoading: false,
setAuthUser: (user) => set({ authUser: user }),
setAccessToken: (token) => set({ access_token: token }),
deleteAccessToken: () => set({ access_token: null }),
setUPloadImage: (isUploading) => set({ uploadImage: isUploading }),
setPageLoading: (isLoading) => set({ pageLoading: isLoading }),
}),
{
name: 'user-storage',
storage: createJSONStorage(() => localStorage),
}
)
)
使用方式就跟 hook
一樣直接呼叫就好,使用起來非常直觀~
const {authUser,access_token,...rest} = useStore()
這邊會是專案會用到的 components
簡單介紹一下~
import React, { useState } from "react";
import { twMerge } from "tailwind-merge";
type SpinnerProps = {
className?: string;
};
export const Spinner = ({ className }: SpinnerProps) => {
const cn = twMerge(
'w-8 h-8 mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-blue-600',
className,
)
return (
<div role="status">
<svg aria-hidden="true"
className={cn}
viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor" />
<path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill" />
</svg>
<span className="sr-only">Loading...</span>
</div>
);
};
import React from 'react'
import { Spinner } from './Spinner'
export const FullScreenLoader = () => {
return (
<div className='fixed top-0 left-0 w-screen h-screen flex items-center justify-center bg-black/20'>
<Spinner className='w-8 h-8' />
</div>
)
}
import React, { PropsWithChildren } from 'react'
import { Spinner } from './Spinner';
interface LoadingButtonProps extends PropsWithChildren {
loading: boolean;
btnColor?: string;
textColor?: string;
}
export const LoadingButton = ({ loading, btnColor, textColor, children }: LoadingButtonProps) => {
return (
<button
type='submit'
className={`w-full py-3 font-semibold ${btnColor} rounded-lg outline-none border-none flex justify-center ${loading ? 'bg-[#ccc]' : ''}`}
>
{loading ? (
<div className='flex items-center gap-3'>
<Spinner />
<span className='text-slate-500 inline-block'>Loading...</span>
</div>
) : (
<span className={`${textColor}`}>{children}</span>
)}
</button>
)
}
>npm i react-hook-form zod @hookform/resolvers
import { register } from 'module';
import React, { ChangeEvent } from 'react'
import { FieldValues, UseFormRegister, Path, FieldError } from 'react-hook-form';
interface FormInputProps<SchemaType extends FieldValues> {
label: string;
register: UseFormRegister<SchemaType>
name: Path<SchemaType>,
error: FieldError | undefined
type?: string;
placeholder?: string;
}
export const FormInput = <SchemaType extends FieldValues>({
label,
name,
register,
error,
type = 'text',
placeholder = ''
}: FormInputProps<SchemaType>) => {
return (
<div>
<label htmlFor={name} className='block text-ct-blue-600 mb-3'>
{label}
</label>
<input
type={type}
placeholder={placeholder}
className='block w-full rounded-2xl appearance-none focus:outline-none py-2 px-4'
{...register(name)}
/>
{error && (
<p className='text-red-500 text-xs pt-1 '>{error.message}</p>
)}
</div>
)
}
import React, { ChangeEvent, useState } from 'react'
import { Control, Controller, FieldError, FieldValues, Path, UseFormRegister, UseFormSetValue, useController, useFormContext } from 'react-hook-form'
import { useStore } from '../store'
import { Spinner } from './Spinner'
interface FileUploadProps<SchemaType extends FieldValues> {
label: string,
register: UseFormRegister<SchemaType>
name: Path<SchemaType>,
// control: Control<SchemaType, any>,
onChange: (e: ChangeEvent<HTMLInputElement>) => void
error: FieldError | undefined
}
export const FileUpload = <SchemaType extends FieldValues>({ label, error, name, register, onChange }: FileUploadProps<SchemaType>) => {
const { uploadImage } = useStore()
return (
<>
<div className='mb-2 flex justify-between items-center'>
<div>
<label htmlFor={name} className='block text-ct-blue-600 mb-3'>
{label}
</label>
<input
{...register(name)}
type="file"
className='block text-sm mb-2 text-slate-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-violet-50 file:text-violet-700 hover:file:bg-violet-100'
onChange={onChange}
accept='image/*'
multiple={false}
/>
<div>
{uploadImage && <Spinner className='text-yellow-400' />}
</div>
</div>
</div>
{error && (
<p className='text-red-500 text-xs italic mb-2'>
{error.message}
</p>
)}
</>
)
}
這邊會透過 react toastify
去顯示 user
的操作。
> npm install react-toastify
isAuth
: 檢查 zustand
是否有 access_token
。api.auth.logoutUser
: logout
後是清除 access_token
變更新 query cache
資料然後前往登入頁面。
import React, { useMemo } from 'react'
import { QueryClient } from 'react-query'
import { useStore } from '../store'
import { api } from '~/utils/trpc'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { toast } from 'react-toastify'
import { Spinner } from './Spinner'
import { FullScreenLoader } from './FullScreenLoader'
export const Header = () => {
const apiContext = api.useContext()
const { access_token, pageLoading, setAccessToken, deleteAccessToken } = useStore()
const isAuth = useMemo(() => !!access_token, [access_token])
const router = useRouter()
const { mutateAsync: logout } = api.auth.logoutUser.useMutation({
onSuccess: () => {
apiContext.invalidate()
router.replace('/login')
setAccessToken('')
toast.success('success logout')
},
onError: (error) => {
toast.error(error.message)
},
})
const handleLogout = async () => {
await logout()
deleteAccessToken()
}
return (
<>
<header className='bg-white h-20'>
<nav className='h-full flex justify-between items-center container'>
<div>
<Link href={'/'} className='text-ct-dark-600'>token demo</Link>
</div>
<ul className='flex items-center gap-4'>
<li>
<Link href={'/'} className='text-ct-dark-600'>home</Link>
</li>
{!isAuth ? (
<>
<li>
<Link href={'/register'} className='text-ct-dark-600'>SignUp</Link>
</li>
<li>
<Link href={'/login'} className='text-ct-dark-600'>Login</Link>
</li>
</>
) : (
<>
<li>
<Link href={'/profile'} className='text-ct-dark-600'>Profile</Link>
</li>
<li className="cursor-pointer" onClick={handleLogout}>
Logout
</li>
</>
)}
</ul>
</nav>
</header>
{pageLoading && (
<FullScreenLoader />
)}
</>
)
}
這邊得 provider
同時兼具 layout
。
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import React, { PropsWithChildren } from 'react'
import { ToastContainer } from 'react-toastify'
import { Header } from './Header'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
export const queryClient = new QueryClient({})
interface ProviderProps extends PropsWithChildren { }
export const Provider = ({ children }: ProviderProps) => {
return (
<QueryClientProvider client={queryClient}>
<Header />
{children}
<ReactQueryDevtools initialIsOpen={false} />
<ToastContainer />
</QueryClientProvider>
)
}
目前常見的 image uploader
功能很多例如 S3
但這邊筆者想推薦一套蠻有趣的 file upload
的 saas
cloudinary
,除了基本的 image
上傳下載外,還提供變形的功能,甚至是套濾鏡,功能非常多讀者有興趣可以慢慢參考~
https://cloudinary.com/
首先要登入後台,可以從後台中拿取之後會用到的環境變數。
cloudinary
有提供兩種方式:
以及 cloudinary
對於圖片上傳有兩種機制:
cloudinary
換取 signature
驗證。upload_preset
指定 upload
的一些 rule
。兩者都是有驗證的保護,但差別在於 authenticated
是 role base
但 Unauthenticated
是 rule base
。
那這邊因為打算用 Unauthenticated requests
方式,所以我們需要去後台設定 upload_preset
等於 Unsigned
才能以 Unauthenticated requests
方式 upload file
。
如此一來我們就可以用 Unauthenticated requests
upload file
拉~
cloudinary
很貼的的是可以針對 upload
的 image
做裁切,設定方式一樣在 upload preset
中可以修改如下~
最後別忘記放上 env
NEXT_PUBLIC_CLOUDINARY_APIKEY=""
NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET=""
NEXT_PUBLIC_CLOUDINARY_SECRET=""
NEXT_PUBLIC_CLOUDINARY_URL=""
NEXT_PUBLIC_CLOUDINARY_NAME=""
在 cloundary
中他有提供蠻多的 api
使用,甚至如果doc
太多看不完可以直接看 postman
的內容如下~
https://www.postman.com/cloudinaryteam/workspace/programmable-media/overview
這邊是採用 upload api
的內容,讀者可以根據自己喜好調整其他參數~值得注意的是upload api
是採用 form data
方式, Content-Type
記得改成 multipart/form-data
。
在 cloundinary
要上傳圖片可以用以下的 router
,並把 form data
當成 payload
使用。
POST https://api.cloudinary.com/v1_1/:cloud_name/image/upload
const uploadImageToCloudinary = async (e: ChangeEvent<HTMLInputElement>) => {
const image = e.target.files?.[0]
if (!image) return
const validateResult = uploadFormSchema.safeParse({ photo: e.target.files })
if (!validateResult.success) return
let formdata = new FormData()
formdata.append("file", image);
formdata.append("upload_preset", process.env.NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET);
formdata.append("public_id", image.name);
formdata.append("api_key", process.env.NEXT_PUBLIC_CLOUDINARY_APIKEY);
formdata.append("tags", 'profile');
formdata.append("folder", user?.name as string);
setPageLoading(true)
const result: UploadImageApiResponse = await fetch(`https://api.cloudinary.com/v1_1/${process.env.NEXT_PUBLIC_CLOUDINARY_NAME}/image/upload`, {
method: 'POST',
body: formdata,
headers: { "Content-Type": "multipart/form-data" },
redirect: 'follow'
})
.then(response => response.json())
.catch(error => console.log('error', error))
.finally(() => {
setPageLoading(false)
})
}
uploadImageToCloudinary
的參數介紹:
file
: 檔案內容。upload_preset
: storage acess
中的 Upload presets name
。public_id
: 當圖片上傳到 cloudinary
後會提供resource
的 url
例如:
https://res.cloudinary.com/doqktivlj/image/upload/v1696163499/public_id.png`
如果指定 public_id=public_id
那你的 url
結尾就會是 public_id
,預設是隨機亂數,這個參數蠻方便的是可以讓你找尋圖片網址的 url
可以透過 public_id
的輸入去查詢。
tags
: image
的 metadata
,可用於查詢圖片種類。folder
: image
的資料夾,目的用來區分哪些 user
上傳哪些圖片。
https://github.com/Danny101201/refetch-token/tree/main
✅ 前端社群 :
https://lihi3.cc/kBe0Y