5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

GeekSalonAdvent Calendar 2023

Day 8

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

Last updated at Posted at 2023-12-07

はじめに

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

やること

Reactでfirebaseのfirestoreとstorageの機能を使って画像と文字列をセットで投稿し、表示する機能を作成していきます
storageに画像をアップロードし、そのアップロードした画像と文字をセットで保存しておくためにfirestoreを使います
image.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
テストモードで開始します

Reactアプリの作成

npx create-react-app my-app --template typescript
npm install firebase
npm install image-validator

image-validatorは投稿する画像を制限するときに使います
(今回はファイルサイズ3GB未満かつ画像ファイルのみ)

構成(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
image.png

各ファイルの中身

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コンポーネントで処理しています

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
Firestore

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から画像を取得して表示している

公式ドキュメント

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を基準に並べ替えて返します

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へのアクセスに必要です

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

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

export default Image;
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システム使うだけです

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>

実行

ここまで全てのコードがいけてたら、次の画像のようになっているはずです

npm start

image.png
また、投稿した画像や文字たちは、firebaseのコンソール画面で確認できます

firestore

image.png

strage

image.png
image.png

終わり

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

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

ばいばいき〜ん

5
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?