Twitterクローンを作ります #12 つぶやきの表示

今回はつぶやきの表示をしていきますが、そのまえにいくつか細かい点を修正しておきましょう。

# 細かい点の修正

まずはログイン必須のページにログインしていない状態でアクセスされた際に、ログイン画面へリダイレクトしてあげる処理を追加しましょう。

```javascript:client/src/main.js

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'

import UIkit from 'uikit'
import Icons from 'uikit/dist/js/uikit-icons'
import 'uikit/dist/css/uikit.min.css'
UIkit.use(Icons)

router.beforeEach((to, from, next) => {
  if (to.matched.some(record => record.meta.loginRequired)) {
    const user = store.state.userLoggedIn;
    if (user && user._id) {
      next();
    } else {
      next({ path: '/login' });
    }
  } else {
    next();
  }
});

createApp(App).use(store).use(router).mount('#app')
```

`router/index.js`の各ルートのmetaでログインが必須かどうかを指定してあげて、それに応じて遷移時にログイン状態をチェックするような処理にしました。

ということで、`router/index.js`も修正しましょう。

```javascript:client/src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'

const routes = [
  {
    path: '/',
    name: 'home',
    meta: { loginRequired: true },
    component: HomeView
  },
  {
    path: '/register',
    name: 'register',
    meta: { layout: 'simple-layout' },
    component: () => import('../views/RegisterView.vue')
  },
  {
    path: '/login',
    name: 'login',
    meta: { layout: 'simple-layout' },
    component: () => import('../views/LoginView.vue')
  }
]

const router = createRouter({
  history: createWebHistory(process.env.BASE_URL),
  routes
})

export default router
```

現段階ではHomeViewのみログイン必須ですね。
もともとアクセスしようとしていたURLを保持しておいてログイン後にそちらへリダイレクトさせてあげてもいいかもですが今回はこのままにしておきましょう。

次につぶやき編集画面のアバター画像が決め打ちになってしまっているのでこちらを修正します。
つぶやきの表示でもつかいそうなので、使い回せるかたちにしておきましょう。

```javascript:client/src/functions/avatar.js
const imageUrl = user => {
  if (!user || !user.display_name) {
    return 'https://ui-avatars.com/api/?name=?';
  }

  if (!user.profile_pic) {
    return `https://ui-avatars.com/api/?name=${user.display_name}`;
  }
  return user.profile_pic;
}

export { imageUrl };
```

早速これを利用する形に修正しましょう。

```vue:client/src/components/PostEdit.vue
<template>
    <form @submit="onSubmit">
        <div class="uk-flex uk-flex-top">
            <img :src="url" class="uk-border-circle avatar">
            <textarea
                v-model="content"
                placeholder="いまどうしてる？" 
                class="uk-textarea uk-margin-left textarea"></textarea>
        </div>
        <div class="uk-flex uk-flex-right">
            <input 
                class="uk-button uk-button-primary uk-margin-right" 
                type="submit" 
                value="ツイートする"
                :disabled="isInvalid">
        </div>
    </form>
</template>

<script>
import { computed, ref } from 'vue';
import { useStore } from 'vuex';

import { imageUrl } from '@/functions/avatar.js';

export default {
    setup() {
        const store = useStore();

        const content = ref('');

        const isInvalid = computed(() => {
            const value = content.value;
            if (value.trim().length === 0) {
                return true;
            }

            if (value.length > 140) {
                return true;
            }

            return false;
        });

        const onSubmit = e => {
            e.preventDefault();

            store.dispatch('createPost', content.value)
                .then(success => {
                    if (success) {
                        content.value = '';
                    }
                });
        };

        const url = imageUrl(store.state.userLoggedIn);
        return {
            content,
            onSubmit,
            isInvalid,
            url
        };
    },
}
</script>

...
```

長くなってきたのでこれくらいにして本題にはいりましょう。

# つぶやきの表示

まずは画面から作っていきます。

```vue:client/src/components/PostPanel.vue
<template>
    <div class="uk-margin-right">
        <div class="uk-flex uk-flex-top">
            <img src="https://ui-avatars.com/api/?name=?" class="uk-border-circle avatar">
            <div class="uk-flex uk-flex-left uk-flex-column uk-margin-left">
                <div class="uk-flex uk-flex-middle uk-flex-row">
                    <span class="uk-text-bold">hoge</span>
                    <span class="uk-margin-small-left uk-text-muted">@fuga</span>
                    <span class="uk-margin-small-left uk-text-muted">4時間前</span>
                    <span class="uk-flex-1"></span>
                    <span uk-icon="icon: close; ratio: 0.8"></span>
                </div>
                <div class="uk-flex uk-margin-small-top">
                    Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 
                </div>
                <div class="uk-flex uk-flex-between uk-margin-small-top">
                    <span class="uk-flex-1" uk-icon="comment"></span>
                    <span class="uk-flex-1" uk-icon="bolt"></span>
                    <span class="uk-flex-1" uk-icon="heart"></span>
                </div>
            </div>
        </div>
        <hr>
    </div>
</template>

<style scoped>
.avatar {
    width: 50px;
    height: 50px;
}
</style>
```

![スクリーンショット 2022-03-14 19.51.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2536525/6c958dfd-1bec-ae3a-bd5e-5c637521aa19.png)

ダミーデータを使ってざざっと作ってみました。UIKitにリツイートのアイコンがなかったので適当なものにしました。
Post.vueという名前にしたかったのですが、そういえば1単語のコンポーネントは作れなかったのでPostPanel.vueにしました。

# APIの実装

本来的にはフォローしていないユーザーのつぶやきは表示しないとか色々あるのですが、フォロー機能などまだ作っていないのでとりあえずDBにあるつぶやきを全部返してしまいましょう。


```py:server/apis/posts.py

...

@app.route('/api/posts', methods=['GET'])
@login_required()
def list_posts():
    results = []
    for post in Post._collection.find(sort=[('created_at', pymongo.DESCENDING)]):
        post['_id'] = str(post['_id'])
        posted_by = User._collection.find_one({'_id': post['posted_by']})
        if posted_by:
            posted_by['_id'] = str(posted_by['_id'])
            post['posted_by'] = posted_by
        results.append(post)
    return results
```

node.jsのmongooseにはpopulateというメソッドがあるのですが、pymongoには無いようなので自前で実装しています。
これとか、_idの文字列化とかあとでutil化しておきましょう。

次に画面側のアクションを定義していきましょう。

# アクションの定義

```javascript:client/store/index.js
import { createStore } from 'vuex'
import router from '@/router';

import axios from 'axios';
import UIkit from 'uikit';

export default createStore({
  state: {
    userLoggedIn: undefined,
    posts: []
  },
  getters: {
  },
  mutations: {
...
  },
  actions: {
...
    },
    loadPosts ({ commit }) {
      axios.get('/api/posts').then(resp => {
        commit('setPosts', resp.data);
      })
      .catch(error => {
        if (error.response.status === 401) {
          router.push('/login');
        } else {
          UIkit.notification(error.response.data.error.message, {status: 'danger'});
        }
      });
    }
  },
  modules: {
  }
})

```

とりあえずこんなものでしょう。呼び出し処理を書いていきます。

APIの呼び出しはHomeViewから行います。

```vue:client/src/views/HomeView.vue
<template>
  <div>
    <h3 class="uk-margin-top">Home</h3>
    <post-edit></post-edit>
    <hr class="uk-margin-right">
    <div>
      <post-panel v-for="post in posts" :key="post._id" :post="post"></post-panel>
    </div>
  </div>
</template>

<script>
import { onMounted, computed } from 'vue';
import { useStore } from 'vuex';
import PostEdit from '@/components/PostEdit.vue';
import PostPanel from '@/components/PostPanel.vue';

export default {
  components: {
    PostEdit,
    PostPanel
  },
  setup() {
    const store = useStore();

    onMounted(() => {
      store.dispatch('loadPosts');
    });

    return {
      posts: computed(() => store.state.posts)
    };
  },
}
</script>
```

onMountedで画面描画時につぶやきの取得APIを呼び出し、その結果が反映されるように computed にしています。

次にPostPanelをpostを受け取ってその内容を表示するように修正しましょう。

```vue:client/src/components/PostPanel.vue
<template>
    <div class="uk-margin-right">
        <div class="uk-flex uk-flex-top">
            <img :src="url" class="uk-border-circle avatar">
            <div class="uk-flex uk-flex-left uk-flex-column uk-margin-left uk-flex-1">
                <div class="uk-flex uk-flex-middle uk-flex-row">
                    <span class="uk-text-bold">{{ post.posted_by.display_name }}</span>
                    <span class="uk-margin-small-left uk-text-muted">@{{ post.posted_by.username }}</span>
                    <span class="uk-margin-small-left uk-text-muted">{{ post.created_at }}</span>
                    <span class="uk-flex-1"></span>
                    <span uk-icon="icon: close; ratio: 0.8"></span>
                </div>
                <div class="uk-flex uk-margin-small-top">
                    {{ post.content }}
                </div>
                <div class="uk-flex uk-flex-between uk-margin-small-top">
                    <span class="uk-flex-1" uk-icon="comment"></span>
                    <span class="uk-flex-1" uk-icon="bolt"></span>
                    <span class="uk-flex-1" uk-icon="heart"></span>
                </div>
            </div>
        </div>
        <hr>
    </div>
</template>

<script>
import { imageUrl } from '@/functions/avatar.js';

export default {
    props: {
        post: Object
    },
    setup(props) {
        const url = imageUrl(props.post.posted_by);
        return {
            url
        };
    },
}
</script>

<style scoped>
.avatar {
    width: 50px;
    height: 50px;
}
</style>
```

これで投稿が表示されるようになりました。

![スクリーンショット 2022-03-15 15.10.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2536525/dd47e6d5-4ce9-e589-642f-231d4685cbe1.png)