iT邦幫忙

2023 iThome 鐵人賽

DAY 28
0

大綱

  1. 使用者頁面開發
  2. 更新使用者資料

1. 使用者頁面開發

Wireframe

https://ithelp.ithome.com.tw/upload/images/20231013/201365587NWpSwNktY.jpg

建立檔案

一樣先在pages資料夾底下建立Profile.js
在這個頁面我們需要根據路由的使用者ID來取得當前作者的資料和他發布的文章。

import { useEffect,useState } from 'react';
import { useParams } from 'react-router-dom';
import PostItem from '../components/PostItem';
import api from '../api/api';


const ProfilePage = (props) => {
    //作者發布的文章資料
    const [postList, setPostList] = useState([]);
    const [loading, setLoading] = useState(true);
    //作者的個人資料
    const [author, setAuthor] = useState({});
    //作者的ID
    const { userId } = useParams();
    
    //當前登入的使用者ID
    const currentUserId = JSON.parse(localStorage.getItem("user")).data.id;

    useEffect(() => {
       //要確保兩個api都呼叫完成後才將Loading動畫關閉
        Promise.all([
            getUserInfo(),
            getUserPosts()
          ])
          .then(([userInfo, userPosts]) => {
            setAuthor(userInfo);
            setPostList(userPosts);
          })
          .catch((error) => {
            alert('An error occurred:', error.message);
            console.error(error);
          })
          .finally(() => {
            setLoading(false);
          });
      },[])
  
    //取得作者資訊
    const getUserInfo = () =>{
        return api.get(`users/${userId}/profile`)
        .then((result) => {
            return result.data;
        });
    } ;
    
    //取得作者發布的文章
    const getUserPosts = () =>{
        return api.get(`users/${userId}/posts`)
        .then((result) => {
            return result.data;
        });
    } ;
  
    //若資料還在載入中
    if (loading) {
    return <div className="absolute top-1/2 left-1/2 -translate-y-1/2 -translate-x-1/2 text-4xl">
        Loading...
    </div>;
    }
    

    return <div className="w-[600px] mx-auto mt-20">
        <div className="flex items-center">
            {/*...*/}
            <div className="ml-4">
                <p className="font-bold text-xl">{author.fullName}</p>
                <p>{author.bio}</p>
                <p className="text-sm text-gray-400">Join Date: <span className="ml-1">{author.joinDate}</span></p>
                {/* 判斷當前作者是不是和登入者相同,是的話顯示編輯個人資料按鈕*/}
                {
                    currentUserId === userId &&
                    <button className="text-violet-600 cursor-pointer hover:text-violet-800">Edit Profile</button>
                }
            </div>
        </div>
       
        <div className="py-8"> 
            { postList.map(post => <PostItem key={post._id} post={post} id={post._id}/>)}
        </div>

    </div>

};

export default ProfilePage;

App.js路由設置

//App.js
import './App.css';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { AuthProvider } from './contexts/AuthContext';
...
import ProfilePage from './pages/Profile';

const router = createBrowserRouter([
  {
    path: '/', 
    element: <RootLayout/>,
    children:[
      {...}
      { path: '/profile/:userId', element: <ProfilePage />},
    ]
  },
  {
    path: '/login', 
    element: <Login/>,
  },
  {
    path: '/register', 
    element: <Register/>,
  }
])

const App = () => {
 ...
}

export default App;

PostItem的使用者名字連結設置

https://ithelp.ithome.com.tw/upload/images/20231013/20136558znRz2SWdUS.jpg

當我們點選該作者的名字時應該要能導到作者資訊頁,此外在這裡也新增了isShowAuthor,判斷是否要顯示作者名稱,因為在使用者資訊頁時,不需要顯示作者名字(因為那邊會撈回的文章資料就是該作者的)。

    <Link to={`/profile/${id}`}>
        <p className="text-violet-600 ml-2 text-sm">
          {author.fullName}
        </p>
    </Link>

PostItem程式碼

import Tag from "./Tag";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";

const PostItem = ({ post, id, isShowAuthor }) => {

  {/*...*/}
  
  return (
   <div className="mb-8">
        <Link to={`/posts/${id}`}>
            {/*...*/}
        </Link>
        {isShowAuthor ? (
          <div className="flex items-center mt-4">
            {author.profileImage ? (
              <div className="w-[28px] h-[28px] rounded-full border border-gray-200 overflow-hidden">
                <img src={author.profileImage} alt="avatar" />
              </div>
            ) : (
              <div className="w-[32px] h-[32px] rounded-full border bg-gray-400 text-white text-center leading-[32px] overflow-hidden">
                {author.fullName[0]}
              </div>
            )}
            {/* 到使用者資訊頁的連結 */}
            <Link to={`/profile/${author._id}`}>
              <p className="text-violet-600 ml-2 text-sm">{author.fullName}</p>
            </Link>
            <p className="text-sm text-gray-400 ml-3 text-sm">{createdDate}</p>
          </div>
        ) : (
          <p className="text-sm text-gray-400 py-2 text-sm">{createdDate}</p>
        )}
     </div>
  );
};

export default PostItem;

🔺同上,HomeItem.jsTagItem.js裡有作者名稱也要記得加上作者資訊頁的連結。


調整PostList.js

因為加了isShowAuthor的屬性,在PostList.js裡的PostItem也要加上這個屬性

//PostList.js

const PostList = props =>{
return ...(略)
 <div className="py-8"> 
    { postList.map(post => <PostItem key={post._id} post={post} id={post._id} isShowAuthor={true}/>)}
 </div>
}
export default PostList;

2. 更新使用者資料

Wireframe

https://ithelp.ithome.com.tw/upload/images/20231014/20136558GhEFu0AuyG.jpg

pages底下新增EditProfile.js

App.js路由設置

//App.js
import './App.css';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { AuthProvider } from './contexts/AuthContext';

{...}
import EditProfile from './pages/EditProfile'; //路由設置

const router = createBrowserRouter([
  {
    path: '/', 
    element: <RootLayout/>,
    children:[
      {...}
      { path: '/profile/edit', element: <EditProfile />},
    ]
  },
  {...}
])

const App = () => {
  ...
}

export default App;

EditProfile.js

更新使用者資訊和更新文章內容其實很類似,頭像圖片的處理就和文章封面圖片一樣。

一樣先設定會用到的變數

 const navigate = useNavigate();

  const validateRequired = (value) => {
    if (value.trim() === "") {
      return { isValid: false };
    }
    return { isValid: true };
  };

  const [profileImage, setProfileImage] = useState("");
  const [file, setFile] = useState(null);
  const [loading, setLoading] = useState(false);

  //使用者姓名
  const {
    value: fullName,
    isValid: fullNameIsValid,
    hasError: fullNameInputHasError,
    valueChangeHandler: fullNameChangeHandler,
    inputBlurHandler: fullNameBlurHandler,
    setEnteredValue: setFullName,
  } = useInput(validateRequired);
  
  //使用者簽名檔,因為非必填所以不會存取hasError跟isValid
  const {
    value: bio,
    valueChangeHandler: bioChangeHandler,
    inputBlurHandler: bioBlurHandler,
    setEnteredValue: setBio,
  } = useInput((value) => {
    return true;
  });

接著進頁面時要透過當前登入使用者的Id來取得資料

    //從當前localstorage取得使用者id
    const userId = JSON.parse(localStorage.getItem("user")).data.id;
    
    useEffect(() => {
        getUserInfo();
    }, []);

    //呼叫API取得使用者資料
    const getUserInfo = () =>{
        api.get(`/users/${userId}/profile`)
        .then((result) =>{
          const author = result.data;
          //設定資料
          setAuthor(author);
          setProfileImage(author.profileImage);
          setFullName(author.fullName);
          setBio(author.bio);
        })
        .catch((error) =>{
          alert('An error occurred:',error.message);
          console.error(error);
        });
    }

其他部分和更新文章一樣,這裡就直接附上完整的程式碼

import api from "../api/api";
import useInput from "../hooks/useInput";
import { useNavigate, useLocation, useParams } from "react-router-dom";
import { useEffect, useState } from "react";
import { AiFillEdit } from "react-icons/ai";

const EditProfile = (props) => {
  const navigate = useNavigate();

  //從當前路由取得使用者id
  const { userId } = useParams();
 
  const validateRequired = (value) => {
    if (value.trim() === "") {
      return { isValid: false };
    }
    return { isValid: true };
  };

  const [profileImage, setProfileImage] = useState("");
  const [file, setFile] = useState(null);
  const [loading, setLoading] = useState(false);

  const {
    value: fullName,
    isValid: fullNameIsValid,
    hasError: fullNameInputHasError,
    valueChangeHandler: fullNameChangeHandler,
    inputBlurHandler: fullNameBlurHandler,
    setEnteredValue: setFullName,
  } = useInput(validateRequired);

  const {
    value: bio,
    valueChangeHandler: bioChangeHandler,
    inputBlurHandler: bioBlurHandler,
    setEnteredValue: setBio,
  } = useInput((value) => {
    return true;
  });

  useEffect(() => {
    getUserInfo();
  }, []);

  const getUserInfo = () =>{
    api.get(`/users/${userId}/profile`)
    .then((result) =>{
      const author = result.data;
      setAuthor(author);
      setProfileImage(author.profileImage);
      setFullName(author.fullName);
      setBio(author.bio);
    })
    .catch((error) =>{
      alert('An error occurred:',error.message);
      console.error(error);
    });
  }


  //更新使用者資料
  const updateProfile = async () => {
    try {
      const profile = {
        fullName,
        bio,
        profileImage,
      };
      await api.put(`/users/${userId}/profile`, profile);
    } catch (error) {
      throw error;
    }
  };

  //頭像圖片上傳
  const uploadImage = async () => {
    const formData = new FormData();
    formData.append("image", file);

    try {
      const response = await api.post("/images/upload", formData, {
        headers: {
          "Content-Type": "multipart/form-data",
        },
      });
      return response.data.data.url;
    } catch (error) {
      throw error;
    }
  };

  //選擇檔案時的處理
  const onFileChange = (e) => {
    const selectedFile = e.target.files[0];
    console.log(selectedFile);
    setFile(selectedFile);

    // 如果使用者選擇了圖片,則生成一個預覽URL並設定它
    if (selectedFile) {
      const reader = new FileReader();
      reader.onload = function (event) {
        setProfileImage(event.target.result);
      };
      reader.readAsDataURL(selectedFile);
    }
  };

  //Submit
  const onSubmit = async (e) => {
    e.preventDefault();
    if (!formIsValid) return;
    //若沒有選擇圖片或當前圖片url為null時(如果本來就沒有頭像應該為空字串)
    if (profileImage === null && file === null) {
      alert("Please select image!");
      return;
    }
    setLoading(true);
    try {
      //判斷圖片url,如果file有資料就表示使用者有選取新的圖片
      const imageUrl = file ? await uploadImage() : profileImage;
      await updateProfile(imageUrl);
      alert("Profile update successfully!");
      navigate(`/profile/${userId}`);
    } catch (error) {
      console.error("An error occurred:", error);
      alert("An error occurred:", error.message);
    } finally {
      setLoading(false);
    }
  };

  let formIsValid = false;

  if (fullNameIsValid) {
    formIsValid = true;
  }

  const fullNameInputClasses = fullNameInputHasError
    ? "border-red-500 focus:ring-red-500"
    : "border-violet-300 focus:ring-violet-500";

  const avatarClasses = profileImage
    ? "border-gray-200"
    : "bg-gray-400 text-white text-center text-3xl leading-[100px]";

  return (
    <div className="w-[400px] mx-auto mt-20 rounded mt-8 p-8 shadow-xl border border-gray-200">
      <section className="mb-8 flex justify-center relative">
        <div
          className={`w-[100px] h-[100px] rounded-full overflow-hidden border border-gray-200 relative ${avatarClasses}`}
        >
          {profileImage ? (
            <img src={profileImage} alt="profile" />
          ) : (
            <span> {author.fullName[0]}</span>
          )}
        </div>
        <label className="absolute rounded-full border border-gray-300 bg-white p-1 text-gray-800 text-sm right-[36%] bottom-2 cursor-pointer">
          <AiFillEdit />
          <input
            className="hidden"
            type="file"
            accept="image/jpeg, image/png"
            onChange={onFileChange}
          />
        </label>
      </section>
      <form onSubmit={onSubmit}>
        <div className="mb-4">
          <h3 className="text-l mb-1">FullName</h3>
          <input
            type="text"
            value={fullName}
            placeholder="Your FullName"
            onChange={fullNameChangeHandler}
            onBlur={fullNameBlurHandler}
            className={`mt-1 block w-full px-3 py-2 bg-white border 
                        rounded-md text-sm shadow-sm placeholder-slate-400
                        focus:outline-none focus:ring-1 ${fullNameInputClasses}`}
          />
          {fullNameInputHasError && (
            <p className="text-red-500 text-sm">FullName is required</p>
          )}
        </div>
        <div className="mb-8">
          <h3 className="text-l mb-1">Bio</h3>
          <textarea
            rows={5}
            value={bio}
            placeholder="Bio"
            onChange={bioChangeHandler}
            onBlur={bioBlurHandler}
            className="mt-1 block w-full px-3 py-2 bg-white border 
                        rounded-md text-sm shadow-sm placeholder-slate-400
                        focus:border-violet-300 focus:ring-violet-500
                        focus:outline-none focus:ring-1"
          ></textarea>
        </div>
        <button
          type="submit"
          disabled={loading || !formIsValid}
          className="px-4 py-2 bg-violet-600 hover:bg-violet-700  duration-200 text-white w-full rounded cursor-pointer disabled:opacity-50 disabled:cursor-auto"
        >
          {loading ? "Uploading..." : "Update"}
        </button>
      </form>
    </div>
  );
};

export default EditProfile;

其他路由設置

編輯使用者頁面可以透過作者資訊頁(Profile.js)裡的EditProfile button進入,另外也能從Header.js的Account Setting進入,所以我們要去這兩個檔案設定路由到EditProfile.js

//Header.js
<Menu.Items className="absolute right-0 mt-2 w-56 origin-top-right bg-white shadow-lg focus:outline-none flex flex-col rounded">
    <Menu.Item as={Fragment}>
      <Link to={"profile/edit"}>
        <p className="px-2 py-3">Account settings</p>
      </Link>
    </Menu.Item>
    ...
  </Menu.Items>
//Profile.js
<div className="ml-4">
                <p className="font-bold text-xl">{author.fullName}</p>
                <p>{author.bio}</p>
                <p className="text-sm text-gray-400">Join Date: <span className="ml-1">{author.joinDate}</span></p>
                {
                    currentUserId === userId &&
                    <Link to={`/profile/edit`}>
                        <button className="text-violet-600 cursor-pointer hover:text-violet-800">Edit Profile</button>
                    </Link>
                }
</div>

上一篇
[Day27]文章內容頁開發
下一篇
[Day29] 後端程式部署(render + google cloud storage)
系列文
初探全端之旅: 以MERN技術建立個人部落格31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言