実務未経験文系大学生が独学で制作したReact/firebaseのWebサイト

こんにちは。ゆうたと申します。

タイトルの通り、プログラミング初心者の私がポートフォリオ用のwebサイトを作ったので紹介させていただきます。

それにあわせて独学の苦悩なども書きたいと思います。

わたしと同じ実務未経験でプログラミングをやっている人は共感できることも多いと思います。

この記事は、
- **only text**(React/firebaseで制作したWebサイト)の紹介
- **未経験でたった一人で制作すること**の苦悩

の構成で書いていきます。

この記事を読んでくださっている方のほとんどはわたしより経験や知識が豊富だと思うのでそういった方々のご指摘点があればコメントしてもらえるとうれしいです。

# only text(React/firebaseで制作したWebサイト)の紹介
[**only text**](https://onlytext.net)
githubはこちらです↓
https://github.com/yuutauh/first
![スクリーンショット ホーム画面.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3067746/23aad811-a302-d2b6-5c17-94c157168788.png)

このサイトは**文字のみ**の投稿を重視したソーシャルメディアを目指したWebサイトです。

ホーム画面や投稿の詳細では投稿者にモザイクがしてあり、**いいね**をしたときにモザイクが解除され投稿者がわかるようになっています。

**詳細画面** 
![いいね画面.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3067746/fb36efc1-20e0-466e-8713-0061dbc45ba2.gif)


なんでこんな仕様にしたかというと、インスタやtwitterをはじめとしたソーシャルメディアは**画像やショート動画などで溢れかえって**います。

投稿内容は似たり寄ったりなのに**有名な人だけが評価を独り占め**している印象を受けます。

そのため**文字のみで内容を判断**して、ホーム画面では誰によって投稿されたかもわからないようにすればそこにソーシャルメディアとして新しさがあるのかなと思い実装しました。
　　
### 使用した技術
:ballot_box_with_check: **React**
:ballot_box_with_check:**Firebase**

### only textの画面ごとの機能
:ballot_box_with_check:　**デザインについて**
とても悩んで探しまくった上選ばれたのは**neumorphism**でした。

**パソコン**にも**スマホ**にも対応しています。レスポンシブデザインです。

:ballot_box_with_check:　**ホーム画面**
![ホーム画面.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3067746/f278a99a-6381-d81b-c28d-932c21113a4e.gif)
ホーム画面では投稿されたスレッドを新しい順にソートしています。

ページネーションに関して、twitterのようにスクロールするだけで次のスレッドを表示する**無限スクロール**をしています。

:ballot_box_with_check:　**投稿画面**
![投稿画面.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3067746/34410507-510b-b9b2-b1b8-d0233c6a76ed.gif)
・文字列のみの投稿となります。250文字以上はバリデーションをかけています。

・**タグ付け**と**タグの作成**ができます。やりやすいように**タグの検索**

・**制作したタグは自動でスレッドに付与**され、投稿後は**タグリスト**に保存されてみんなが使えるようになります。

・**制作したタグ**と**すでにタグリストにあるタグ**の名称が重複した場合は自動的にすでにタグリストにあるタグが付与されます（同じ名前のタグが何個もあるのを防ぐため）

・一つの投稿につき**５つまでタグ付け**できます。

・**匿名**（**初回アクセス時に自動で匿名ログインされます**）での投稿も可能です。

:ballot_box_with_check:**タグリスト**と**タグの詳細**
![タグリスト.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3067746/b385207e-8f1e-e29c-8416-d79a50e72d53.gif)
・**投稿画面で制作されたタグ**の一覧が表示されています

・ホーム画面と同じく**無限スクロール**仕様

・タグの詳細画面は**各ユーザーがタグをフォローできる**ようになっていて、**フォロワー順、投稿が新しい順、古い順にソート**できます

:ballot_box_with_check:**詳細画面**
![詳細画面.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3067746/20b610e7-2bac-59d0-e602-ef3dbc66d57a.gif)
・高評価（**いいね**）や低評価（**いやだね**）**コメント**をすることができます。

・付与したタグが表示され**コメントにも高評価、低評価**できます。

・hostingとfunctionsを連携してこのページのみ**SSR**して動的にしています。

・**Twitterへの共有**も可能です。共有内容はシンプルにスレッドの文字列と画像です。↓
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3067746/95e058b5-6bd8-b36e-fddd-40c81dc8573c.png" width=50%>

:ballot_box_with_check:**プロフィール画面**
![スクリーンショット プロフィール.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3067746/356e4742-1f13-6c91-9ed3-0547c4f1fa0d.png)
・ユーザーのサムネはログインしたアカウントに依存します。

・**フォロワー**と**フォロー**のユーザーリスト

・**フォローしたタグの表示**

・**ユーザーが投稿したスレッド**と**お気に入りしたスレッド**の一覧

・**無限スクロール** 　などなど

＊DM（ダイレクトメッセージ）機能は追加予定です...

:ballot_box_with_check:**検索画面**
![スクリーンショット 検索画面.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3067746/991e85fc-11f0-f2ee-c8b3-45622498b3e7.png)
・**スレッド、タグ、ユーザー**の検索ができます
# 技術面
firebaseの基本的なセットアップは割愛します。
ルーティングは以下の通りです。
react-router-domをつかってルーティングしてます。
```js:Home.js
//ルーティング以外のコードは省略しています
import { BrowserRouter, Route, Switch } from 'react-router-dom';

const Home = () => {
return(
<BrowserRouter>
    <Switch>
    	<Route path="/body/:body">
          //詳細画面へ
    	  <ShowBubble />
        </Route>
    	<Route path="/tags/:tag">
         //タグの詳細画面へ
          <Tags />
    	</Route>
    	<Route exact path="/profile/:profile">
         //各ユーザーにプロフィール画面へ
          <Profile />
    	</Route>
    	<Route path="/search">
         //検索画面へ
          <Search /> 
    	</Route>
    	<Route path="/tagindex">
         //タグの一覧画面へ
    	  <TagIndex />
    	</Route>
    	<Route exact path="/login">
         //ログイン画面へ
          <Login />
    	</Route>
    	<Route exact path="/tos">
         //利用規約へ
    	  <Tos />
    	</Route>
    	<Route exact path="/privacy">
         //プライバシーポリシーへ
    	  <Privacy />
    	</Route>
         /*ログインしてない場合はログイン画面に遷移するようにしてますが
           匿名ログインできるため不要でした */
    	<InputRouter path="/input" component={Input} />
    	<Route path="/">
          //Feedsとなっていますがホーム画面です
          <Feeds />
    	</Route>
    </Switch>
</BrowserRouter>
)
}
```
### Firebase Authenticationとユーザー周り
- 先ほどのルーティングでユーザー情報取得のために工夫します。
-> `React.createContext`で`AuthContext`を作成
-> `onAuthStateChanged`で取得した`currentUser`を`AuthContext`の`value`に設定
-> `Home`コンポーネントを`AuthContext`でラップ
-> `currnetUser`は`AuthContext`下のどの階層でも参照できるようになります
```js:Auth.js
//Auth以外のコードは省略しています
//AuthContextを作成
export const AuthContext = React.createContext();
 
export const Auth = ({children}) => {
	const [loading, setLoading] = useState(true);
	const [currentUser, setCurrentUser] = useState(null);

	useEffect(() => {
        //ユーザー情報を取得。currentUserステートにuserを更新
		const unsubscribe = auth.onAuthStateChanged((user) =>{
            //ユーザーがログインしていない場合には匿名ログイン
			if(!user) {
				fb
				.auth()
				.signInAnonymously()
				.then((user) => {
					setCurrentUser(user);
					setLoading(false)
				})
			} else {
				setCurrentUser(user);
				setLoading(false)
			}
		})

		return unsubscribe
	}, []);

	return (
        //valueにcurrentUserを設定。
		<AuthContext.Provider value={{currentUser}}>
            //children = home.js
			{ !loading &&  children }
		</AuthContext.Provider>
	)
}
```

useContextについてはこちらの公式サイトを参考にしてください↓
https://ja.reactjs.org/docs/context.html
- ログインが成功した場合、投稿やフォロワーなどユーザー情報を保存しなければならないので**firestore**の`user`コレクションに保存します。
->`signInWithPopup(googleProvider or twitterProvider)`からユーザー情報を取得
->firestoreの`set`メゾットでユーザー情報を`user`コレクションに保存
->すでにログインしているユーザーを重複して保存しないためにmergeオプションをつけます

```js:Login.js
//googleログインの関数
 const authWithGoogle = () => {
    fb.auth()
      .signInWithPopup(googleProvider)
      .then((user) => {
        if (!user) {
          return;
        }
        const o = {
          uid: user.user.uid,
          displayName: user.user.displayName,
          photoURL: user.user.photoURL,
        };
         //mergeオプション追加
        db.collection("users").doc(user.user.uid).set(o, { merge: true });
      })
  };
//twitterログインの関数
  const authWithTwitter = () => {
    fb.auth()
      .signInWithPopup(twitterProvider)
      .then((user) => {
        if (!user) {
          return;
        }
        const o = {
          uid: user.user.uid,
          displayName: user.user.displayName,
          photoURL: user.user.photoURL,
        };
        db.collection("users").doc(user.user.uid).set(o, { merge: true });
      })
  };
```
もっと上手な方法があると思います。

### Firestore
**スレッド**及び**タグ**のデータ構造は以下の通りです。
```json:threads.json
{
  "threads": [
    {
      "userimage": "", //ユーザーのサムネ 
      "favorites": [],  //お気に入りしたユーザーのuidの配列
      "body": "",      //投稿された文字列
      "favoriteCount": 0,  //お気に入りされると＋１
      "tagname": [],   //付与されたタグの名前の配列
      "badCount": 0,   //いやだねされると＋１
      "username": "",  //投稿したユーザーの名前
      "uid": "",       //投稿したユーザーにuid
      "id": "",        //uuid()でランダムの文字列
      "bads": [],      //いやだねしたユーザーのuidの配列
      "created": { "seconds": 1665565359, "nanoseconds": 661000000 }
    },
    //以下略
  ]
```

```json:tags.json
{
  "tags": [
    {
      "id": "",       //uuid()でランダムの文字列
      "userId": [],   //フォローしたユーザーのuid
      "userCount": 0,  //フォローされたら＋１
      "created": { "seconds": 1664349563, "nanoseconds": 399000000 },
      "name": "",      //タグの名前
      "threadId": [],
      "threadCount": 0  //付与された投稿の個数
    },
    //以下略
  ]
}
```
- inputされた文字列が`body`に格納されます。
- 投稿画面で指定されたタグを`tagname`に格納します。
- 付与されたタグが**タグリストにあるタグ**　or　**新しく作成されるタグ**で挙動が分かれます。
- **タグリストにあるタグ**の場合、`threadId`が更新されてスレッドの`id`が追加されます。
- **新しく作成されるタグ**の場合、firestoreのtagコレクションに新しくtagが作成されます。
- **firestoreセキュリティールール**のコードの公開は避けますが、`threads`と`tags`にそれぞれ**データスキーマが正しいか** ,**正しくログインされているか**、**ユーザーがどの認証プロバイダーを利用しているか**　などなど設定してあります。
```js:firestore.rules
//匿名,google,twitter認証であるか判定する
function isUserValid(auth) {
        return auth.token.firebase.sign_in_provider in ['twitter.com', 'google.com','anonymous']
}
```
**Firestore Security Rules**についてこちらを参考にさせていただきました。
- [Firebase Authentication 7つの落とし穴 - 脆弱性を生むIDaaSの不適切な利用](https://blog.flatt.tech/entry/firebase_authentication_security)
- [Firestore Security Rules Setup | Advanced Firebase/Firestore](https://www.youtube.com/watch?v=hQI_w4AZ92I&list=LL&index=250)

### SSR(サーバーサイドレンダリング)
- **firebase hosting**と**cloud functions**を連携させて`/body/{bodyId}`のパスにリクエストが来た場合、`returnHtmlWithOGP`をよびだしています。
- `/body/{bodyId}`のパスは**投稿の詳細画面**になっています。
- 公式にもやり方があるので参考までに↓

https://firebase.google.com/docs/hosting/full-config
```js:firebase.json
"hosting": {
     //省略
    "rewrites": [
      {
        "source": "/body/*",
        "function": "returnHtmlWithOGP"
      },
      {
        "source": "**",
        "destination": "/index.html"
      }
    ]
  },
```
```js:functions/index.js
exports.returnHtmlWithOGP = functions.https.onRequest((req, res) => {
	res.set('Cache-Control', 'public, max-age=300, s-maxage=600')
	// Access URL '/body/{bodyId}'
	const [, , bodyid] = req.path.split('/')
	const domain = 'https://onlytext.net'
	let indexHTML = fs.readFileSync('./hosting/index.html').toString()
  
	db.collection('threads').doc(bodyid).get()
		.then((snapshot) => {
		  const thread = snapshot.data()
		  let body = thread.body
		  indexHTML
			.replace(/\<title>.*<\/title>/g, '<title>' + body + '</title>')
			.replace(/<\s*meta name="description" content="[^>]*>/g, '<meta name="description" content="' + body + '" />')
			.replace(/<\s*meta property="og:title" content="[^>]*>/g, '<meta property="og:title" content="' + body + '" />')
			.replace(/<\s*meta property="og:url" content="[^>]*>/g, '<meta property="og:url" content="' + domain + '" />')
			.replace(/<\s*meta property="og:description" content="[^>]*>/g, '<meta property="og:description" content="' + body + '" />')
			.replace(/<\s*meta name="twitter:card" content="[^>]*>/g, '<meta name="twitter:card" content="summary_large_image" />')
		  res.status(200).send(indexHTML)
		})
		.catch(err => {
		  res.status(404).send(indexHTML)
		})
})

```
**SSR**についてはこちらの記事を参考にさせていただきました
- [Cloud FunctionsでSSRして、OGPタグを動的に書き換える](https://shuheitagawa.com/set-ogp-tags-with-cloudfunctions/#:~:text=%E3%81%AB%E8%BF%94%E3%81%97%E3%81%BE%E3%81%99%E3%80%82-,Meta%20tag%E3%81%AE%E3%81%BF%E3%80%81SSR%E3%81%99%E3%82%8B,-%E4%BB%8A%E5%9B%9E%E3%81%AE%E8%A8%98%E4%BA%8B)

###  検索機能
- firebaseで検索するには外部エンジンが必要でお金がかかるらしいのでそれらを避ける形にしました。
- 外部エンジンを使っていないので検索ワードの最初の文字が一致しないとダメな大変厳しい仕様。
```js:searchProfile.js
const SearchProfile = ({ index, setIndex }) => {
  const [search, setSearch] = useState("");
  const [result, setResult] = useState([]);
  const [error, setError] = useState("");
   //省略

  const SearchTag = (term) => {
    if (typeof term == "string") {
      const searchTerm = term.toLowerCase();
      const strlength = searchTerm.length;
      const strFrontCode = searchTerm.slice(0, strlength - 1);
      const strEndCode = searchTerm.slice(strlength - 1, searchTerm.length);
      const endCode =
        strFrontCode + String.fromCharCode(strEndCode.charCodeAt(0) + 1);

      db.collection("users")
        .where("displayName", ">=", searchTerm)
        .where("displayName", "<", endCode)
        .limit(10)
        .get()
        .then((docs) => {
          if (docs.size == 0) {
            setError("該当するユーザーがいません...");
            setResult([]);
          } else {
            const items = [];
            docs.forEach((doc) => {
              items.push(doc.data());
            });
            setResult(items);
            setError("");
          }
        });
    }
  };
  return(
   <>
     //省略

     <input
      placeholder="user を検索"
      type="text"
      value={search}
      onChange={(e) => {
        setSearch(e.target.value);
      }}
      />
        <button onClick={() => { SearchTag(search) }}>
          <i className="uil uil-search"></i>
        </button>
  </>
)
}
```
- 正直に言って、なぜこのコードで検索ができるのか細かい説明ができません泣（firestoreの仕様なんでしょうか？）
- こちらのサイトを参考にしました
[Google Firestore: Query on substring of a property value (text search)](https://stackoverflow.com/questions/46568142/google-firestore-query-on-substring-of-a-property-value-text-search)
### 無限スクロール
- こちらのサイトを参考にしました
[React & Infinite Scroll - IntersectionObserver](https://www.youtube.com/watch?v=GVDiw3lAyp0&list=LL&index=159)

###  反省点

- **バズる要素がなにもない**こと。
- **Reactをあまり生かせていない、理解しきれていない**（コンポーネントにすべきところを何度も同じ処理をかいてしまっている）
- **変数、関数、CSSの命名が雑**でコードの訂正が大変。他人が見たら絶対にわからないです。
- アニメーションがない
などなど

### 一人で制作することの苦悩
- **決まらないデザイン**
**欲をかいて一向にすすまない**。
figmaやdribbble、pinterest を参考して、いざ自分のサイトに実装してみると、なんかピンと来ない。ピンとくるまで自分でなんとか修正する。
→さらに良さそうなのがあったこれにしよう
→いざ実装、なんかピンと来ない、修正
→さらに良さそう…
の無限ループ
CSSを忘れるのでその度に検索
どこかであきらめるしかないです

- **実装したい仕様の検索が大変**
例えば、投稿された内容をtwitterに共有したいとします。
そこでtwitter 共有などなど色々検索するわけですが、なかなか欲しい情報が手に入りません。
この場合twitterに限らずSNSに投稿をシェアしたい場合はOGPの設定をすることで実装でき**OGPと検索することで欲しい情報が手に入る**わけですが、
**OGPという単語に行き着くまでが独学だととても大変**です(調べてから実際にコードを書くのも大変ですが) 
私の場合、
firebase関連の言葉（今でも)
ソート
無限スクロール
初回アクセス時のモーダルのポップアップ
アニメーション
などなど
**頭の中でなんとなく実装のイメージ出来てるのに、なんと検索していいのかわからない**
このもどかしさは初心者には厳しいものがあります

- **どうしても限界がある**

youtubeで紹介されたとおりにコーディングしてもうまくいかないことがあります。

**nodeはライブラリの変化が激しいことが原因らしいです**。すでに使えないライブラリで紹介されていたらコーディングしても時間の無駄になりますし、**そもそも使えないライブラリであるかの判断も厳しい**です。

**うまくいかないコードだと判断したら諦めて他を探す**ことが重要だと感じました。


# おわりに
最後まで読んでいただきありがとうこざいます。

冒頭にも書きましたがなにかご指摘などがあればコメントしてください。