iT邦幫忙

2023 iThome 鐵人賽

DAY 26
0

大綱

  1. Wireframe
  2. Quill.js介紹
  3. 發布文章頁面開發

1. Wireframe

https://ithelp.ithome.com.tw/upload/images/20231011/20136558OD3c8lraOg.png

2. Quill.js介紹

Quill 是一個開源且極具彈性的富文本編輯器,提供多樣的文字格式化選項與易於操作的 API,並且支援跨瀏覽器使用和自訂化設定。它擁有即時協作功能,能讓多名使用者同時在文件上編輯和合作。
更多Quill的介紹請見官方

那什麼是Rich text editor?

富文本編輯器(Rich Text Editor)是一個使用者介面元件,它允許使用者以可視化的方式編輯格式化的文本。富文本通常指的是包含了一些格式的文本,例如不同種類的字體、顏色、大小以及包含連接、圖片和其他多媒體元素的文本。
在網頁或應用程式中,當使用者使用富文本編輯器時,他們能夠利用編輯工具欄上的按鈕來設置文本格式,例如將文字加粗、斜體或者下底線,插入連結等,而無需手動撰寫HTML或Markdown。
https://ithelp.ithome.com.tw/upload/images/20231011/20136558ugzJY4iPaT.png

在IT邦發文的時候我們也是使用Rich Text Editor

3. 發布文章頁面開發

先建立發布文章的檔案,在pages資料夾底下新增EditPost.js(到時候會跟編輯文章共用)
這次主要功能重點

  • 使用TagsInput元件
  • 使用ReactQuill
  • 圖片上傳後再呼叫發布文章api

使用TagsInput元件

//EditPost.js
import React, { useState, useMemo  } from "react";
import { TagsInput } from "react-tag-input-component";
import api from '../api/api';
import useInput from "../hooks/useInput";

const EditPost = (props) => {

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

 //設定title相關屬性
 const {
    value: title,
    isValid: titleIsValid,
    hasError: titleInputHasError,
    valueChangeHandler: titleChangeHandler,
    inputBlurHandler: titleBlurHandler,
  } = useInput(validateRequired);

  //設定tag相關屬性
  const [tags, setTags] = useState([]);
  const [tagsTouched, setTagsTouched] = useState(false);
  
  //設定tag驗證
  const tagsIsValid = tags.length > 0 ;
  const tagsInputIsInValid = !tagsIsValid && tagsTouched;
  
  //tagInput blur事件
  const handleTagsInputBlur  = () =>{
    setTagsTouched(true);
  }
   
  //從localstorage取得authorId
  const authorId = JSON.parse(localStorage.getItem("user")).data.id;
   
  const titleInputClasses = titleInputHasError ? 'border-red-500 focus:ring-red-500' : 'border-slate-300 focus:ring-sky-500'  ;
  const tagsInputClasses = tagsInputIsInValid ? 'border-red' : 'border-slate';
  
  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>
          <input 
            type="file" 
            accept="image/jpeg, image/png" 
            onChange={onFileChange} 
          />
        </div>
        <div className="mb-8">
          <h3 className="text-2xl mb-1">Content</h3>
          {/* 到時候放置react-quill的地方 */}
        </div>
        <div className="text-right">
          <button className="bg-violet-600 text-white px-6 py-2 rounded disabled:opacity-50"  type="submit" disabled={loading || !formIsValid}>
            {loading ? 'Uploading...' : 'Submit'}
          </button>
        </div>
      </form>
    </div>
  );
};

export default EditPost;

上面我們把標題欄位和標籤欄位的驗證和值設定完成。
🔺另外提醒的是,在TagsInput裡不使用handleChange function是因為它回傳的是string[]而不是event,所以才會直接呼叫setTags

  <TagsInput
    value={tags}
    classNames={{ input: tagsInputClasses}}
    onChange={setTags}
    onBlur={handleTagsInputBlur}
    name="tags"
    placeHolder="輸入文章分類"
  />

詳細文件請見react-tag-input-component


使用ReactQuill

先前已經安裝過react-quill
所以直接在檔案上引用。

import React, { useState, useMemo  } from "react";
import { useNavigate } from "react-router-dom";
import { TagsInput } from "react-tag-input-component";
//引用ReactQuill
import ReactQuill from "react-quill";
//引用ReactQuill的編輯器樣式
import 'react-quill/dist/quill.snow.css';
import api from '../api/api';
import useInput from "../hooks/useInput";


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,
  } = useInput(validateRequired);


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

  //編輯器的內容
  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;

  let formIsValid = false;

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

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

  //Content處理,onChange事件可以拿到 (content, delta, source, editor)
  const handleContentChange = (value, delta) => {
    setContent(value);
  };
  
  //Content blur事件
  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 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>
          <input 
            type="file" 
            accept="image/jpeg, image/png" 
            onChange={onFileChange} 
          />
        </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 className="bg-violet-600 text-white px-6 py-2 rounded disabled:opacity-50"  type="submit" disabled={loading || !formIsValid}>
            {loading ? 'Uploading...' : 'Submit'}
          </button>
        </div>
      </form>
    </div>
  );
};
export default EditPost;

這樣設置完成後應該就能看到react-quill了
https://ithelp.ithome.com.tw/upload/images/20231011/20136558Fds58B2WZn.jpg


圖片上傳後再呼叫發布文章api

import React, { useState, useMemo  } from "react";
import { useNavigate } 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";


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,
  } = useInput(validateRequired);

  //圖片檔案存取
  const [file, setFile] = useState(null);

  //loading狀態,當api還在處理時為true,完成則設回為false
  const [loading, setLoading] = useState(false);

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

  
  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;

  let formIsValid = false;

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

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

  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) =>{
    setFile(e.target.files[0]);
  }

  //圖片上傳API
  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',
        }
      });
      //若圖片成功上傳會回傳圖片的url
      return response.data.data.url; 
    } catch (error) {
      throw error;
    }
  };

  //發布文章API
  const createPost = async (imageUrl) => {
    try {
      const postData = {
        title,
        content,
        coverImage: imageUrl,
        authorId: authorId,
        tags: tags,
      };

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

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

  //發布文章   
  const onSubmit = async (e) =>{
    e.preventDefault();
    //若沒有選取檔案,則顯示錯誤訊息
    if(file === null){
      alert('Please select cover image!');
      return;
    }
    //設定狀態為載入中
    setLoading(true);
    try {
      //先呼叫圖片上傳api取得圖片url
      const imageUrl = await uploadImage();
      //接著呼叫發布文章API
      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>
          <input 
            type="file" 
            accept="image/jpeg, image/png" 
            onChange={onFileChange} 
          />
        </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 className="bg-violet-600 text-white px-6 py-2 rounded disabled:opacity-50"  type="submit" disabled={loading || !formIsValid}>
            {loading ? 'Uploading...' : 'Submit'}
          </button>
        </div>
      </form>
    </div>
  );
};

export default EditPost;

程式碼

今日程式碼在此

參考資料


上一篇
[Day25] 文章列表頁和標籤頁開發
下一篇
[Day27]文章內容頁開發
系列文
初探全端之旅: 以MERN技術建立個人部落格31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言