React Typescript Firebaseで画像投稿機能作ってみた

# はじめに
この記事はReactを勉強しはじめて、firebaseの偉大さを知り、個人開発レベルなら無料でも十分大活躍のその機能をもっと知りたいと日々試行錯誤していたとき、firebaseのstrageには画像を保存できることはわかったけど、それだけじゃサイトに投稿した画像を表示したり、他のものと一緒に投稿できないやんと思いこの記事を書いています
なのでここから下では画像と文字列をセットで投稿し、投稿されたものをサイト内で一覧表示する方法について書いています。

# やること
Reactでfirebaseのfirestoreとstorageの機能を使って画像と文字列をセットで投稿し、表示する機能を作成していきます
storageに画像をアップロードし、そのアップロードした画像と文字をセットで保存しておくためにfirestoreを使います
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2586059/f6163d05-50be-c9a5-22cb-5864cf64a244.png)

# firebaseの設定
https://console.firebase.google.com/?hl=ja
プロジェクト作成→ウェブアプリ追加

コピーしておく↓↓↓
```
// Import the functions you need from the SDKs you need
import { initializeApp } from "firebase/app";
import { getAnalytics } from "firebase/analytics";
// TODO: Add SDKs for Firebase products that you want to use
// https://firebase.google.com/docs/web/setup#available-libraries

// Your web app's Firebase configuration
// For Firebase JS SDK v7.20.0 and later, measurementId is optional
const firebaseConfig = {
  apiKey: "***************",
  authDomain: "***************",
  projectId: "***************",
  storageBucket: "***************",
  messagingSenderId: "***************",
  appId: "***************",
  measurementId: "***************"
};

// Initialize Firebase
const app = initializeApp(firebaseConfig);
const analytics = getAnalytics(app);
```
Firebase DatabaseとStorageの設定をしていく
「構築」の中に2つともあります
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2586059/86fda61e-db8d-4b54-b1a1-2068f80e7481.png)
テストモードで開始します

# Reactアプリの作成
```
npx create-react-app my-app --template typescript
npm install firebase
npm install image-validator
```
`image-validator`は投稿する画像を制限するときに使います
（今回はファイルサイズ3GB未満かつ画像ファイルのみ）
```text:構成(srcフォルダのみ)
src/
  ├ components/
    ├ Post.tsx
    └ PreviewImage.tsx
  ├ hooks/
    └ useImages.tsx
  ├ lib/
    └ firebase.ts
  ├ types/
    └ image.d.ts
  ├ utils/
    └ formatTimestamp.ts
  └ App.tsx
  ...
```

#### 小話
vscodeで`tsrafce`って書いてTabをおすと雛形作ってくれます
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2586059/2bfda7f2-0e37-dbbf-dc56-4dce1e54df8d.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2586059/7724730f-ce50-dde8-02bc-8a51f8646e40.png)

### 各ファイルの中身
```typescript:App.tsx
import React from "react";
import "./App.css"
import Post from "./components/Post";
import useImages from "./hooks/useImages";
import PreviewImage from "./components/PreviewImage";

const App: React.FC = () => {
  const { allImages, imagesError } = useImages();
  return (
    <div className="App">
      <Post />
      <h3>投稿一覧</h3>
      <p style={{ color: "red" }}>{imagesError && imagesError}</p>
      <div className="container">
        <div className="row">
          {allImages.map((image, index) => (
            <div className="col-4" key={index}>
              <PreviewImage image={image} />
            </div>
          ))}
        </div>
      </div>
    </div>
  );
};

export default App;

```
useImagesはのちに出てきますが、firestoreから投稿を全て取得する自作のhooksです
allimagesでは画像はfileName(string)として持っているだけで、storageから取って来ているわけではありません
実際に画像をstorageから取って来ているのは、PreviewImageコンポーネントで処理しています

```typescript:components/Post.tsx
import React, { useState, ChangeEvent } from "react";
import { ref, uploadBytes } from "firebase/storage";
import { validateImage } from "image-validator";
import { db, storage } from "../lib/firebase";
import { addDoc, collection } from "firebase/firestore";

const Post: React.FC = () => {
  const [file, setFile] = useState<File | null>(null);
  const [text, setText] = useState<string>("");
  const [imagePreview, setImagePreview] = useState<string | null>(null);
  const [errorMsg, setErrorMsg] = useState<string | null>(null);

  // ファイルのバリデーション。3GB未満でかつ画像ファイルのみに制限している
  const validateFile = async (selectedFile: File): Promise<boolean> => {
    const limitFileSize = 3 * 1024 * 1024;

    if (selectedFile.size > limitFileSize) {
      setErrorMsg("File size is too large, please keep it under 3 GB.");
      return false;
    }

    const isValidImage = await validateImage(selectedFile);

    if (!isValidImage) {
      setErrorMsg("You cannot upload anything other than image files.");
      return false;
    }

    return true;
  };

  // 画像を選択する
  const handleImageSelect = async (e: ChangeEvent<HTMLInputElement>) => {
    setErrorMsg(null);
    e.preventDefault();
    const selectedFile = e.target.files?.[0];

    if (selectedFile && (await validateFile(selectedFile))) {
      const reader = new FileReader();

      reader.onloadend = () => {
        setFile(selectedFile);
        setImagePreview(reader.result as string);
        setErrorMsg(null);
      };

      reader.readAsDataURL(selectedFile);
    }
  };

  // 画像をstorageにアップロードし、firestoreに保存する
  const uploadImage = async () => {
    try {
      if (!file) {
        setErrorMsg("File not selected.");
        return;
      }

      const timestamp = new Date().getTime();
      const uniqueFilename = `${timestamp}_${file.name}`;
      const storageRef = ref(storage, `images/${uniqueFilename}`);

      // storageにアップロード
      await uploadBytes(storageRef, file);

      // firestoreに保存
      await addDoc(collection(db, "Images"), {
        text,
        fileName: uniqueFilename,
        timestamp: new Date(),
      });

      setErrorMsg("Submission completed!");
    } catch (e) {
      setErrorMsg(`Error: ${e}`);
    }
  };

  return (
    <div>
      <form>
        <input type="file" onChange={handleImageSelect} />
        <br />
        <input
          type="text"
          value={text}
          onChange={(e) => {
            setText(e.target.value);
            setErrorMsg(null);
          }}
        />
        <br />
        <a
          style={{ cursor: "pointer", border: "1px solid gray" }}
          onClick={uploadImage}
        >
          upload
        </a>
      </form>
      <p style={{ color: "red" }}>{errorMsg && errorMsg}</p>
      {imagePreview && (
        <img
          src={imagePreview}
          style={{
            width: "auto",
            height: 200,
            objectFit: "cover",
          }}
          alt="preview"
        />
      )}
    </div>
  );
};

export default Post;
```
画像ファイルがユーザーから選択されたら、テキストとともにfirebaseに送る処理をしている
firestoreには、`現在時刻_選択されたファイル名`という形でfileNameに保存してある(string)
> [Strage](https://firebase.google.com/docs/storage/web/upload-files?hl=ja&authuser=0)
[Firestore](https://firebase.google.com/docs/firestore/manage-data/add-data?hl=ja&authuser=0)

```typescript:PreviewImage.tsx
import React, { useState, useEffect } from "react";
import Image from "../types/image";
import { formatTimestamp } from "../utils/formatTimestamp";
import { ref, getDownloadURL, StorageReference } from "firebase/storage";
import { storage } from "../lib/firebase";

const PreviewImage: React.FC<{ image: Image }> = ({ image }) => {
  const [prevUrl, setPrevUrl] = useState<string>("");
  const [error, setError] = useState<string>("");

  useEffect(() => {
    const getImageUrl = async (imageRef: StorageReference) => {
      try {
        const url = await getDownloadURL(imageRef);
        setPrevUrl(url);
      } catch (e) {
        setError(`${e}`);
      }
    };

    const imageRef = ref(storage, `images/${image.fileName}`);
    getImageUrl(imageRef);
  }, [image.fileName]);

  return (
    <div
      style={{
        boxShadow: "0px 4px 8px gray",
        padding: 10,
        margin: 10,
        width: 350,
        height: 400,
      }}
    >
      <p>
        <img
          src={prevUrl}
          alt={error}
          style={{ height: 200, width: 300, objectFit: "cover" }}
        />
      </p>
      <p>{image.text}</p>
      <p>{formatTimestamp(image.timestamp.toDate())}</p>
    </div>
  );
};

export default PreviewImage;
```
画像のfileNameや一緒に投稿されたテキスト等を受け取り表示するためのコンポーネントです
`getDownloadURL`でstorageから画像を取得して表示している
> [公式ドキュメント](https://firebase.google.com/docs/storage/web/download-files?hl=ja&authuser=0#web-modular-api_1)

```typescript:hooks/useImages.tsx
import { collection, onSnapshot } from "firebase/firestore";
import { useEffect, useState } from "react";
import { db } from "../lib/firebase";
import Image from "../types/image";

const useImages = () => {
  const [allImages, setAllImages] = useState<Image[]>([]);
  const [imagesError, setErrorMsg] = useState<string | null>(null);

  useEffect(() => {
    const unsubscribe = onSnapshot(collection(db, "Images"), (snapshot) => {
      try {
        const imagesData: Image[] = snapshot.docs
          .map((doc) => ({ ...doc.data(), id: doc.id } as Image))
          .sort((a, b) => b.timestamp.toMillis() - a.timestamp.toMillis());
        setAllImages(imagesData);
      } catch (error) {
        setErrorMsg(`Error: ${error}`);
      }
    });

    return () => {
      unsubscribe();
    };
  }, []);

  return { allImages, imagesError };
};

export default useImages;
```
firestoreからImagesというコレクションの中身を全て取得しています
のちに削除機能等で使う可能性があるので一応`id`も取得しています
取得したデータを`timestamp`を基準に並べ替えて返します

```typescript:lib/firebase.ts
import { initializeApp } from "firebase/app";
import { getFirestore } from "firebase/firestore";
import { getStorage } from "firebase/storage";

const firebaseConfig = {
  apiKey: "**********",
  authDomain: "**********",
  projectId: "**********",
  storageBucket: "**********",
  messagingSenderId: "**********",
  appId: "**********",
  measurementId: "**********",
};

const app = initializeApp(firebaseConfig);
export const db = getFirestore(app);
export const storage = getStorage(app);
```
firebaseプロジェクトを作成したときに表示されたコードを少し変えているだけです
storageとfirestoreへのアクセスに必要です

```typescript:types/image.d.ts
import { Timestamp } from "firebase/firestore";

interface Image {
  id: string;
  fileName: string;
  text: number;
  timestamp: Timestamp;
}

export default Image;
```
```typescript:utils/formatTimestamp.ts
export const formatTimestamp = (t: Date) => {
  const date = new Date(t);
  const year = date.getFullYear();
  const month = String(date.getMonth() + 1).padStart(2, "0");
  const day = String(date.getDate()).padStart(2, "0");
  const hours = String(date.getHours()).padStart(2, "0");
  const minutes = String(date.getMinutes()).padStart(2, "0");

  return `${year}/${month}/${day} ${hours}:${minutes}`;
};
```
終わり〜〜

最後に`index.html`に少し追加
Bootstrap用
Gridシステム使うだけです
```diff_html:public/index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="theme-color" content="#000000" />
    <meta
      name="description"
      content="Web site created using create-react-app"
    />
    <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />

    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
    <title>React App</title>
+    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous">
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
+    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz" crossorigin="anonymous"></script>
+    <script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js" integrity="sha384-I7E8VVD/ismYTF4hNIPjVp/Zjvgyol6VFvRkX/vR+Vc4jQkC+hVqc2pM8ODewa9r" crossorigin="anonymous"></script>
+    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.min.js" integrity="sha384-fbbOQedDUMZZ5KreZpsbe1LCZPVmfTnH7ois6mU1QK+m14rQ1l2bGBq41eYeM/fS" crossorigin="anonymous"></script>
  </body>
</html>
```

# 実行
ここまで全てのコードがいけてたら、次の画像のようになっているはずです
```terminal
npm start
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2586059/0a7319eb-808e-02bc-b19f-28950d70537d.png)
また、投稿した画像や文字たちは、firebaseのコンソール画面で確認できます

#### firestore
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2586059/4b52b6fd-0e9f-a689-a983-8b186bf51861.png)

#### strage
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2586059/6357051f-60b3-d9ee-3827-5fbc0a49844e.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2586059/6e67bfee-d8a3-4ed3-2a49-3937dcf71a49.png)


# 終わり
これで、画像投稿の管理ができるようになりました
ここまできたらユーザーが投稿した画像ごとに表示を切り替えたり、複数画像を投稿できるようになりますね〜〜

解説クソテキトーでしたが読んでいただきありがとうございました
何か間違っている点等ありましたらコメントください！

ばいばいき〜ん