iT邦幫忙

2023 iThome 鐵人賽

DAY 1
0
Modern Web

一些讓你看來很強的全端- trcp 伴讀系列 第 27

Day-027. 一些讓你看來很強的全端 TRPC 伴讀 - zustand & cloundinary

  • 分享至 

  • xImage
  •  

昨天完成了 api 內容,今天繼續來補齊前端部分~

error formating

開始之前先補一個昨天忘記說到的內容,首先我們簡單測試一下 api,這邊要注意下 trpcbody是要 {0:{json:{}}} 格式去寫跟 restful 直接寫 fields 不一樣。

https://ithelp.ithome.com.tw/upload/images/20231011/20145677dx0xMPlfIW.png

但這邊會預報一個問題是,假設 req.body 內容不完成,此時看 errors 很難看出來問題在哪邊。

https://ithelp.ithome.com.tw/upload/images/20231011/20145677oRK9Miherb.png

src/server/createRouter.ts

這邊我們可以在 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 的內容就清楚多了~

https://ithelp.ithome.com.tw/upload/images/20231011/20145677PD5TYtD2l1.png

Add the Zustand

前端狀態管理部分打算採用 zustandzustand 的好處是可以非常簡單上手,別於 redux 來說不需要做太多的設定,非常適合用來管理簡單的狀態,zustandredux 一樣有 store 概念,如果想了解更多可以看筆者之前練習的介紹~

install library

> npm install zustand

create store

src/client/lib/types.ts

export type IUser = {
  _id: string;
  id: string;
  email: string;
  name: string;
  role: string;
  photo: string;
  updatedAt: string;
  createdAt: string;
};

src/client/store/index.ts

zustand 中如果要做持久化數據可以透過 middleware 寫法,如底下的 persist function 一層一層包住 create function 的內容,透過 persist functions 你可以指定 storage namestorage 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()

reusable Components

這邊會是專案會用到的 components 簡單介紹一下~

src/client/components/Spinner.tsx
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>
  );
};
src/client/components/FullScreenLoader.tsx
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>
  )
}
src/client/components/LoadingButton.tsx
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>
  )
}
src/client/components/FormInput.tsx
>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>
  )
}


src/client/components/FileUpload.tsx
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>
      )}
    </>
  )
}

src/client/components/Header.tsx

這邊會透過 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 />
      )}

    </>
  )
}
src/client/components/Provider.tsx

這邊得 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

目前常見的 image uploader 功能很多例如 S3 但這邊筆者想推薦一套蠻有趣的 file uploadsaas cloudinary,除了基本的 image 上傳下載外,還提供變形的功能,甚至是套濾鏡,功能非常多讀者有興趣可以慢慢參考~
https://cloudinary.com/

login

首先要登入後台,可以從後台中拿取之後會用到的環境變數。

https://ithelp.ithome.com.tw/upload/images/20231011/20145677AImyy8sx9B.png

go to setting page and click Upload tab

cloudinary 有提供兩種方式:

  1. SDK
  2. resuful api

以及 cloudinary 對於圖片上傳有兩種機制:

  1. authenticated requests : 跟 cloudinary 換取 signature 驗證。
  2. Unauthenticated requests : 透過 upload_preset 指定 upload 的一些 rule

兩者都是有驗證的保護,但差別在於 authenticatedrole baseUnauthenticatedrule base

那這邊因為打算用 Unauthenticated requests 方式,所以我們需要去後台設定 upload_preset 等於 Unsigned 才能以 Unauthenticated requests 方式 upload file

https://ithelp.ithome.com.tw/upload/images/20231011/20145677eF6Txf0mdu.png

https://ithelp.ithome.com.tw/upload/images/20231011/201456777WlvosA1U2.png

如此一來我們就可以用 Unauthenticated requests upload file 拉~

image Transformation

cloudinary 很貼的的是可以針對 uploadimage 做裁切,設定方式一樣在 upload preset 中可以修改如下~

https://ithelp.ithome.com.tw/upload/images/20231011/20145677XbPCsL7dsY.png

env

最後別忘記放上 env

NEXT_PUBLIC_CLOUDINARY_APIKEY=""
NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET=""
NEXT_PUBLIC_CLOUDINARY_SECRET=""
NEXT_PUBLIC_CLOUDINARY_URL=""
NEXT_PUBLIC_CLOUDINARY_NAME=""

demo upload api

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 後會提供resourceurl
例如:
https://res.cloudinary.com/doqktivlj/image/upload/v1696163499/public_id.png`

如果指定 public_id=public_id 那你的 url 結尾就會是 public_id ,預設是隨機亂數,這個參數蠻方便的是可以讓你找尋圖片網址的 url 可以透過 public_id 的輸入去查詢。

tags : imagemetadata,可用於查詢圖片種類。
folder : image 的資料夾,目的用來區分哪些 user 上傳哪些圖片。
https://ithelp.ithome.com.tw/upload/images/20231011/20145677NN6YpGszNJ.png

相關連結

https://github.com/Danny101201/refetch-token/tree/main

✅ 前端社群 :
https://lihi3.cc/kBe0Y


上一篇
Day-026. 一些讓你看來很強的全端 TRPC 伴讀 -Token authorization (JWT )
下一篇
Day-028. 一些讓你看來很強的全端 TRPC 伴讀 -Token authorization ( page )
系列文
一些讓你看來很強的全端- trcp 伴讀30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言