Twitterクローンを作ります #15 いいね機能(2)

前回はいいねのデータを保存するところまで実装したので、いいねの情報を表示していきましょう。

# いいね数の表示

まずはPostPanelでlike_countを表示します。

```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">
                        <span uk-icon="heart" @click="onLikeClick"></span>
                        <span class="uk-margin-small-left">{{ post.like_count ? post.like_count : '' }}</span>
                    </span>
                </div>
            </div>
        </div>
        <hr>
    </div>
</template>

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

export default {
    props: {
        post: Object
    },
    setup(props) {
        const store = useStore();

        const url = imageUrl(props.post.posted_by);

        const onLikeClick = () => {
            store.dispatch('likePost', props.post);
        };
        return {
            url,
            onLikeClick
        };
    },
}
</script>

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

storeで投稿の情報を更新する処理もしていきます。

```javascript:client/src/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: {
...
    },
    updatePost (state, post) {
      const orig = state.posts.find(p => p._id === post._id);
      if (orig) {
        Object.assign(orig, post);
      }
    }
  },
  actions: {
...
    },
    likePost ({ commit }, post) {
      axios.post(`/api/posts/${post._id}/like`).then(resp => {
        commit('updatePost', resp.data);
      })
      .catch(error => {
        if (error.response.status === 401) {
          router.push('/login');
        } else {
          UIkit.notification(error.response.data.error.message, {status: 'danger'});
        }
      });
    }
  },
  modules: {
  }
})

```

postsの該当postを探して、内容を更新してあげるだけで画面の表示が更新されてくれるのがVueなどのフレームワークの利点ですね。

次は投稿取得処理で、自分がいいねした投稿かどうかを付与してあげてハイライトできるようにしましょう。

# APIの修正

まずはPostにlike_countのフィールドを追加しておきましょう。

```python:server/models/post.py
from bson.objectid import ObjectId
from database import db

from datetime import datetime


class Post:
    _collection = db['posts']

    MAX_CONTENT_LENGTH = 140

    def __init__(self,
                content: str,
                posted_by: str,
                like_count: int = 0,
                created_at: datetime = None,
                updated_at: datetime = None):
        self.content = content
        self.posted_by = posted_by
        self.like_count = like_count
        if not created_at:
            created_at = datetime.now()
        self.created_at = created_at
        if not updated_at:
            updated_at = datetime.now()
        self.updated_at = updated_at

...
```

APIを修正していきます。

```python: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)]):
        results.append(_populate(post))
    
    if results:
        user_id = ObjectId(session['user']['_id'])
        start = results[-1]['created_at']
        end = results[0]['created_at']
        liked_posts = { str(like['post']) for like in Like._collection.find({'liked_by': user_id, 'posted_at': {'$gte': start, '$lte': end}}) }
        for result in results:
            result['liking'] = result['_id'] in liked_posts

    return jsonify(results)

...
```

表示する投稿を投稿日時順に取得しているので、その最初と最後の投稿日時を確認して、その範囲の投稿時刻のつぶやきのいいねを取得してlikingに反映しています。

# 自身がいいねした投稿のボタンをハイライト

```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 like" :class="{ active: post.liking }">
                        <span uk-icon="heart" @click="onLikeClick"></span>
                        <span class="uk-margin-small-left">{{ post.like_count ? post.like_count : '' }}</span>
                    </span>
                </div>
            </div>
        </div>
        <hr>
    </div>
</template>

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

export default {
    props: {
        post: Object
    },
    setup(props) {
        const store = useStore();

        const url = imageUrl(props.post.posted_by);

        const onLikeClick = () => {
            store.dispatch('likePost', props.post);
        };
        return {
            url,
            onLikeClick
        };
    },
}
</script>

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

.like.active {
    color: red;
}
</style>
```

![スクリーンショット 2022-03-17 14.14.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2536525/91984158-bd7f-c820-b650-0e2b2ef20a7a.png)

それっぽく表示されるようになりましたね！

# いいねの取り消し

最後に取り消し処理を実装していきましょう。
まずはAPIを追加します。

```python:server/apis/posts.py
...
@app.route('/api/posts/<post_id>/like', methods=['POST'])
@login_required()
def like_post(post_id):
    post = Post._collection.find_one({'_id': ObjectId(post_id)})
    if not post:
        return jsonify({'error': { 'message': '投稿が存在しません' }}), 404
    user_id = session['user']['_id']
    like = Like._collection.find_one({'post': ObjectId(post_id), 'liked_by': ObjectId(user_id)})
    if like:
        post['liking'] = True
        return jsonify(_populate(post))
    else:
        like = Like(post=post_id,
                    posted_at=post['created_at'],
                    liked_by=user_id)
        try:
            like.create()
        except pymongo.errors.DuplicateKeyError:
            pass
        like_count = Like._collection.count_documents({'post': ObjectId(post_id)})
        post = Post._collection.find_one_and_update(
            {'_id': ObjectId(post_id)}, 
            {'$set': {'like_count': like_count}},
            return_document=pymongo.ReturnDocument.AFTER)
        post['liking'] = True
        return jsonify(_populate(post))



@app.route('/api/posts/<post_id>/like', methods=['DELETE'])
@login_required()
def unlike_post(post_id):
    post = Post._collection.find_one({'_id': ObjectId(post_id)})
    if not post:
        return jsonify({'error': { 'message': '投稿が存在しません' }}), 404
    user_id = session['user']['_id']
    like = Like._collection.find_one_and_delete({'post': ObjectId(post_id), 'liked_by': ObjectId(user_id)})
    if not like:
        post['liking'] = False
        return jsonify(_populate(post))
    else:
        like_count = Like._collection.count_documents({'post': ObjectId(post_id)})
        post = Post._collection.find_one_and_update(
            {'_id': ObjectId(post_id)}, 
            {'$set': {'like_count': like_count}},
            return_document=pymongo.ReturnDocument.AFTER)
        post['liking'] = False
        return jsonify(_populate(post))
...
```

最後に画面側を修正していきましょう。

```javascript:client/src/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: {
...
    },
    unlikePost ({ commit }, post) {
      axios.delete(`/api/posts/${post._id}/like`).then(resp => {
        commit('updatePost', resp.data);
      })
      .catch(error => {
        if (error.response.status === 401) {
          router.push('/login');
        } else {
          UIkit.notification(error.response.data.error.message, {status: 'danger'});
        }
      });
    }
  },
  modules: {
  }
})
```

```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 like" :class="{ active: post.liking }">
                        <span uk-icon="heart" @click="onLikeClick"></span>
                        <span class="uk-margin-small-left">{{ post.like_count ? post.like_count : '' }}</span>
                    </span>
                </div>
            </div>
        </div>
        <hr>
    </div>
</template>

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

export default {
    props: {
        post: Object
    },
    setup(props) {
        const store = useStore();

        const url = imageUrl(props.post.posted_by);

        const onLikeClick = () => {
            if (props.post.liking) {
                store.dispatch('unlikePost', props.post);
            } else {
                store.dispatch('likePost', props.post);
            }
        };
        return {
            url,
            onLikeClick
        };
    },
}
</script>

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

.like.active {
    color: red;
}

.like {
    cursor: pointer;
}
</style>
```

いいね機能が実装できました。
次回からはリツイートを実装していきましょう。