iT邦幫忙

2023 iThome 鐵人賽

DAY 27
0

大綱

1.文章內容頁開發
2.編輯文章功能開發

1. 文章內容頁開發

Wireframe

https://ithelp.ithome.com.tw/upload/images/20231012/20136558YikXsj2RlJ.jpg

先在pages資料夾底下建立BlogPost.js

//BlogPost.js

import { useEffect,useState } from 'react';
import api from '../api/api';
import Tag  from '../components/Tag'; 
import { useParams } from 'react-router-dom';
import { AiOutlineEdit, AiOutlineDelete} from "react-icons/ai";

const BlogPost = props =>{
    //頁面資料
    const [data, setData] = useState([]);
    
    //載入中畫面的顯示設定
    const [loading, setLoading] = useState(true);
    
    //取得路由上的postId
    const { postId } = useParams();
    
    //取得當前登入使用者的id
    const currentUserId = JSON.parse(localStorage.getItem("user")).data.id;

    useEffect(() => {
        //呼叫取得文章資料的API
        api.get(`/posts/${postId}`)
        .then((result) => {
            setData(result.data);
        })
        .catch((error) => {
            alert('An error occurred:',error.message);
            console.error(error);
        })
        .finally(() => {
            //若成功取得資料則隱藏載入中畫面
            setLoading(false);
        });
    },[])

   //判斷是否顯示載入畫面
  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-[800px] mx-auto">
        <section className="max-h-80 overflow-hidden">
         <img className="w-full h-auto" src={data.coverImage}  alt='cover'/>
        </section>
        <h1 className="text-4xl font-bold  mt-8 mb-4">{data.title}</h1>
        <section className="flex items-center mb-4">
            { data.tags && data.tags.length > 0 && data.tags.map((tag,index) =>  <Tag name={tag} key={index} classes={'mr-2'}></Tag>)}
        </section>
        <section className="flex items-center justify-between">
            <div className="flex items-center mb-4">
                { data.author.coverImage ? 
                    <div className="w-[32px] h-[32px] rounded-full border border-gray-200 overflow-hidden">
                        <img src={data.author.coverImage} alt="avatar" /> 
                    </div>: 
                    <div className="w-[32px] h-[32px] rounded-full border bg-gray-400 text-white text-center leading-[32px] overflow-hidden">
                        {data.author.fullName[0]}
                    </div>
                }
                <div className="ml-4">
                    <p className="text-violet-600 text-sm">{data.author.fullName}</p>
                    <p className="text-gray-400 text-sm tracking-wider">{data.createdDate}</p> 
                </div>
            </div>
            
           {/* 當文章作者和當前使用者一樣時,才會顯示文章編輯和刪除的功能 */}
           { currentUserId ===  data.author._id &&
            <div className="flex items-center">
                <Link to={`/edit-post/${postId}`}>
                    <button 
                        className="text-m flex items-center px-2 py-1  hover:text-violet-700 duration-150 rounded text-violet-600 px-4">
                        <AiOutlineEdit/>
                        <span className="ml-1">編輯文章</span>
                    </button>
                </Link>
               
                <button onClick={handleDeletePost}
                    className="text-m flex items-center px-2 py-1  hover:text-gray-700 duration-150 rounded text-gray-500 px-4">
                    <AiOutlineDelete/>
                    <span className="ml-1">刪除文章</span>
                </button>
            </div> }
           
        </section>
       
        <hr className="border border-gray-100" />
        <div className="py-8 blog-content"> 
            {<div dangerouslySetInnerHTML={{ __html: data.content }} />}
        </div>

    </div>
}

export default BlogPost;

文章內容頁的路由設定

App.js設定路由

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

import Login  from './pages/Login';
import Register from './pages/Register';
import RootLayout from './pages/Root';
import HomePage from './pages/Home';
//文章內容路由
import BlogPost from './pages/BlogPost';
import PostList from './pages/PostList';
import TagList  from './pages/TagList';
import EditPost from './pages/EditPost';

const router = createBrowserRouter([
  {
    path: '/', 
    element: <RootLayout/>,
    children:[
      { path: '/', element: <HomePage />},
      { path: '/posts', element: <PostList />},
      { path: '/posts/:postId', element: <BlogPost />},
      { path: '/tags', element: <TagList />},
      { path: '/edit-post/new', element: <EditPost />},
    ]
  },
  {
    path: '/login', 
    element: <Login/>,
  },
  {
    path: '/register', 
    element: <Register/>,
  }
])

const App = () => {
  return (
    <AuthProvider>
      <RouterProvider router={router}/>
    </AuthProvider>
  );
}

export default App;

設定PostItem的連結

https://ithelp.ithome.com.tw/upload/images/20231013/20136558AhBQTTSIVw.jpg
然後回到PostItem.js去設定連結到文章內容頁,當我們點擊PostItem時要能顯示文章內容。

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

const PostItem = ({post ,id}) =>{
   (略...)
   //在最外層包上`<Link>`,並指向到文章內容的路徑。
   return (
    <Link to={`/posts/${id}`}>
         (略...)
     </Link>
   );
}

export default PostItem;

編輯文章和刪除文章功能

透過取得localstorage的使用者資料內的id,來和當前文章作者的id進行比對。
若相同表示此篇文章作者為當前登入的使用者,此時就可以進行編輯文章刪除文章的動作(顯示按鈕)。

使用者為當前文章作者
https://ithelp.ithome.com.tw/upload/images/20231012/20136558dqdmJzbUjH.jpg

使用者不為當前文章作者
https://ithelp.ithome.com.tw/upload/images/20231012/20136558wVDwGQky51.jpg


程式碼

const BlogPost = props =>{

 //取得當前登入使用者的id
const currentUserId = JSON.parse(localStorage.getItem("user")).data.id;
    (
return
       (略...)
           { currentUserId ===  data.author._id &&
            <div className="flex items-center">
                <Link to={`/edit-post/${postId}`}>
                    <button 
                        className="text-m flex items-center px-2 py-1  hover:text-violet-700 duration-150 rounded text-violet-600 px-4">
                        <AiOutlineEdit/>
                        <span className="ml-1">編輯文章</span>
                    </button>
                </Link>
               
                <button onClick={handleDeletePost}
                    className="text-m flex items-center px-2 py-1  hover:text-gray-700 duration-150 rounded text-gray-500 px-4">
                    <AiOutlineDelete/>
                    <span className="ml-1">刪除文章</span>
                </button>
            </div> }
}
export default BlogPost;
            

按鈕功能說明

  • 刪除 : 點選刪除會直接呼叫刪除文章的API,並在成功刪除後導回文章列表頁
  • 編輯 : 點選編輯會導到文章編輯頁,進行文章編輯動作。

2. 編輯文章內容開發

發布文章和編輯文章使用的是同一個頁面(EditPost.js),因為它們只差在有沒有資料和呼叫的API不同而已,這樣做的好處是能集中管理,不用多去增加一個檔案來維護,但如果兩者要處理的邏輯相差很多還是會建議分兩個檔案。

編輯文章的路由設定

App.js設定編輯文章的路由

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

import Login  from './pages/Login';
import Register from './pages/Register';
import RootLayout from './pages/Root';
import HomePage from './pages/Home';
import BlogPost from './pages/BlogPost';
import PostList from './pages/PostList';
import TagList  from './pages/TagList';
import EditPost from './pages/EditPost';

const router = createBrowserRouter([
  {
    path: '/', 
    element: <RootLayout/>,
    children:[
      { path: '/', element: <HomePage />},
      { path: '/posts', element: <PostList />},
      { path: '/posts/:postId', element: <BlogPost />},
      { path: '/tags', element: <TagList />},
      { path: '/edit-post/new', element: <EditPost />},
      { path: '/edit-post/:postId', element: <EditPost />},
    ]
  },
  {
    path: '/login', 
    element: <Login/>,
  },
  {
    path: '/register', 
    element: <Register/>,
  }
])

const App = () => {
  return (
    <AuthProvider>
      <RouterProvider router={router}/>
    </AuthProvider>
  );
}

export default App;

從文章內容頁把資料帶入編輯頁

我們在文章內容頁BlogPost.js已經取得當前文章的資料,所以點擊編輯文章的按鈕時希望能直接把 BlogPost.js取得的資料帶到EditPost.js,這樣就不用重新呼叫一次取得文章的API了。

那要怎麼做?

<Link> tag上加上state屬性,將要傳入的資料帶入。

<Link to={`/edit-post/${postId}`} state={data}>
    <button 
        className="text-m flex items-center px-2 py-1  hover:text-violet-700 duration-150 rounded text-violet-600 px-4">
        <AiOutlineEdit/>
        <span className="ml-1">編輯文章</span>
    </button>
</Link>

接著到EditPost.js 透過useLocation取得route傳過來的資料

//EditPost.js

import { useNavigate,useLocation } from "react-router-dom";

const EditPost = (props) => {
  //取得從文章內容路由傳進來的資料
  const location = useLocation();
  const passedData = location.state;
  (略...)
}

接著要把取得的資料放到頁面上

//EditPost.js

import { useNavigate,useLocation } from "react-router-dom";

const EditPost = (props) => {
  //取得從文章內容路由傳進來的資料
  const location = useLocation();
  const passedData = location.state;
  
   useEffect(() => {
    //如果有資料,則將資料顯示在畫面上(表示正在編輯)
    if (passedData) {
        setTitle(passedData.title || '');
        setContent(passedData.content || '');
        setTags(passedData.tags || []);
        setImageUrl(passedData.coverImage || '');
    }
   }, [passedData]);
  (略...)
}

然後在現有程式新增updatePost的方法

 //更新文章的API
  const updatePost = async (imageUrl) => {
    try {
      const postData = {
        title,
        content,
        coverImage: imageUrl,
        authorId: authorId,
        tags,
      };
      
      await api.put(`/posts/${passedData._id}`, postData);

    } catch (error) {
      throw error;
    }
  };

另外也要調整最下方的按鈕文字

 <button type="submit" className="bg-violet-600 text-white px-6 py-2 rounded disabled:opacity-50" disabled={loading || !formIsValid}>
            {loading ? 'Uploading...' : passedData ? 'Update' : 'Submit'}
 </button>

完整程式碼

import React, { useState, useMemo,useEffect } from "react";
import { useNavigate,useLocation } from "react-router-dom";
import { TagsInput } from "react-tag-input-component";
import ReactQuill from "react-quill";
import 'react-quill/dist/quill.snow.css';
import api from '../api/api';
import useInput from "../hooks/useInput";
import { AiOutlineFileAdd} from "react-icons/ai";


const EditPost = (props) => {
  const navigate = useNavigate();
  
  const validateRequired = (value) => {
    if (value.trim() === '') {
      return { isValid: false };
    }
    return { isValid: true };
  };

  const validateContent = (content) =>{
    const parser = new DOMParser();
    const doc = parser.parseFromString(content, 'text/html');
    const textContent = doc.body.textContent || "";
    // 檢查是否有非空白字符
    return textContent.trim().length > 0;
  }

  const {
    value: title,
    isValid: titleIsValid,
    hasError: titleInputHasError,
    valueChangeHandler: titleChangeHandler,
    inputBlurHandler: titleBlurHandler,
    setEnteredValue: setTitle
  } = useInput(validateRequired);


  const [file, setFile] = useState(null);

  const [loading, setLoading] = useState(false);

  const [tags, setTags] = useState([]);
  const [tagsTouched, setTagsTouched] = useState(false);

  const [imageUrl, setImageUrl] = useState('');

  
  const [content, setContent] = useState("");
  const [contentTouched, setContentTouched] = useState(false);

  const tagsIsValid = tags.length > 0 ;
  const tagsInputIsInValid = !tagsIsValid && tagsTouched;

  const contentIsValid = validateContent(content) ;
  const contenttIsInValid = !contentIsValid && contentTouched;

  //取得從文章內容路由傳進來的資料
  const location = useLocation();

  const passedData = location.state;


  useEffect(() => {
    //如果有資料,則將資料顯示在畫面上(表示正在進行編輯)
    if (passedData) {
        setTitle(passedData.title || '');
        setContent(passedData.content || '');
        setTags(passedData.tags || []);
        setImageUrl(passedData.coverImage || '');
    }
}, [passedData]);


  
  let formIsValid = false;

  if (titleIsValid && tagsIsValid && contentIsValid ) {
    formIsValid = true;
  }



  const handleTagsInputBlur  = () =>{
    setTagsTouched(true);
  }

  //onChange事件可以拿到 (content, delta, source, editor)
  const handleContentChange = (value, delta) => {
    setContent(value);
  };


  const handleContentBlur = (previousRange, source, editor) => {
    setContentTouched(true);
  };

  const authorId = JSON.parse(localStorage.getItem("user")).data.id;
 
  const modules = useMemo(() => ({
    toolbar: [
        [{ header: [1, 2, 3, false] }],
        [{ font: [] }],
        [
            "bold",
            "italic",
            "underline",
            "strike",
            "blockquote",
            "code-block",
            "link",
            { align: [] },
        ],
    ]
  }), []); 

  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) {
        setImageUrl(event.target.result);
      }
      reader.readAsDataURL(selectedFile);
    }
  }

  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 createPost = async (imageUrl) => {
    try {
      const postData = {
        title,
        content,
        coverImage: imageUrl,
        authorId: authorId,
        tags,
      };

      await api.post('/posts', postData);

    } catch (error) {
      throw error;
    }
  };
  
  //更新文章的API
  const updatePost = async (imageUrl) => {
    try {
      const postData = {
        title,
        content,
        coverImage: imageUrl,
        authorId: authorId,
        tags,
      };
      
      await api.put(`/posts/${passedData._id}`, postData);

    } catch (error) {
      throw error;
    }
  };

     
  const onSubmit = async (e) =>{
    e.preventDefault();
    if(file === null){
      alert('Please select cover image!');
      return;
    }
    setLoading(true);
    try {
      const imageUrl = await uploadImage();
      //透過有沒有passedData判斷是否為編輯
      if(passedData){
        //有passedData,表示是更新
        await updatePost(imageUrl);
        alert('Post update successfully!');
      } else {
        //沒有passedData,表示是發布
        await createPost(imageUrl);
        alert('Post created successfully!');
      }
      navigate("/");
    } catch (error) {
      console.error("An error occurred:", error);
      alert('An error occurred:',error.message);
    } finally {
      setLoading(false);
    }
  }

  const titleInputClasses = titleInputHasError ? 'border-red-500 focus:ring-red-500' : 'border-slate-300 focus:ring-sky-500'  ;
  const tagsInputClasses = tagsInputIsInValid ? 'border-red' : 'border-slate';
  const contentClasses = contenttIsInValid ? 'border border-red-500 focus:border-red-500' : '';
  
  return (
    <div className="w-screen py-16">
      <form className="w-[720px] mx-auto pb-20 pt-10" onSubmit={onSubmit}>
        <h1 className=" mb-4 text-4xl text-black custom-font text-center">Create Post</h1>
        <div className="mb-8">
          <h3 className="text-xl mb-1">Title</h3>
          <input
            type="text"
            value={title}
            placeholder="請輸入文章標題"
            onChange={titleChangeHandler}
            onBlur={titleBlurHandler}
            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 ${titleInputClasses}`}
                    
          />
          {titleInputHasError && <p className="text-red-500 text-sm">Title is required</p>}
        </div>
        <div className="mb-8">
          <h3 className="text-xl mb-1">Tags</h3>
          <TagsInput
            value={tags}
            classNames={{ input: tagsInputClasses}}
            onChange={setTags}
            onBlur={handleTagsInputBlur}
            name="tags"
            placeHolder="輸入文章分類"
          />
          { tagsInputIsInValid  &&  <p className="text-red-500 text-sm">Please enter at least one tag</p>}
        </div>
        <div className="mb-8">
          <h3 className="text-2xl mb-1">Cover Image</h3>
          <div>
            { imageUrl && <img className="mb-4" src={imageUrl} alt="cover"/>}
            <label className="text-violet-600 flex items-center cursor-pointer w-[130px] py-2 px-4 rounded border border-violet-600 hover:border-violet-800 hover:text-violet-800">
                <AiOutlineFileAdd/>
                <span>Select File</span>
              <input 
                className="hidden"
                type="file" 
                accept="image/jpeg, image/png" 
                onChange={onFileChange} 
              />
            </label>
          </div>
          
          
        </div>
        <div className="mb-8">
          <h3 className="text-2xl mb-1">Content</h3>
            <ReactQuill
                theme="snow"
                placeholder="Enter your rich text edtior"
                modules={modules}
                value={content}
                className={contentClasses}
                onBlur={handleContentBlur}
                onChange={handleContentChange}
            />
          {contenttIsInValid && <p className="text-red-500 text-sm">Please enter valid content</p>}
        </div>
        <div className="text-right">
          <button type="submit" className="bg-violet-600 text-white px-6 py-2 rounded disabled:opacity-50" disabled={loading || !formIsValid}>
            {loading ? 'Uploading...' : passedData ? 'Update' : 'Submit'}
          </button>
        </div>
      </form>
    </div>
  );
};

export default EditPost;

參考資料


上一篇
[Day26]發布文章頁面開發和React Quill使用
下一篇
[Day28] 使用者頁面和更新使用者資料開發
系列文
初探全端之旅: 以MERN技術建立個人部落格31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言