7
8

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 5 years have passed since last update.

ニートのプログラミング未経験者がRailsとVueでTodoアプリを作ってみた

Last updated at Posted at 2019-05-27

はじめに

Vuejs と Rails API を使って Todo アプリを作りました。
まずは、ローカル環境で動かし
最終的に Heroku へデプロイするところまで書きました。

最初に作ったものを載せておきます。

デモ

vue.gif

コード

ディレクトリ構成

frontend ディレクトリに Vue のファイルをまとめてあります

vue-rails-api-todo/
├── app
│   ├── channels
│   ├── controllers
│   ├── jobs
│   ├── mailers
│   ├── models
│   └── views
├── bin
├── config
├── db
├── docs
├── frontend
│   ├── dist
│   ├── node_modules
│   ├── public
│   └── src
├── lib
├── log
├── public
├── storage
├── test
├── tmp
└── vendor

対象読者(こんな方に読んでいただけたら)

Rails と Vue のチュートリアルを勉強してなにか作ってみたい方

事前準備

Rails と VueCLI3 のインストールを行なってください

自分の環境です

Mac MoJava
ruby 2.6.1
Rails 5.2.3
Vue 3.7.0

【Rails】サーバーサイドの作成

Rails プロジェクトを API モードで作る

terminal
rails new vue-rails-api-todo --api

Gemfile を修正

Gemfile

source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }

ruby '2.6.1'
gem 'bootsnap', '>= 1.1.0', require: false
gem 'puma', '~> 3.11'
gem 'rack-cors'
gem 'rails', '~> 5.2.3'
gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby]

group :development, :test do
  gem 'byebug', platforms: %i[mri mingw x64_mingw]
  gem 'sqlite3'
end

group :development do
  gem 'listen', '>= 3.0.5', '< 3.2'
  gem 'pry-byebug'
  gem 'pry-doc'
  gem 'pry-rails'
  gem 'pry-stack_explorer'
  gem 'spring'
  gem 'spring-watcher-listen', '~> 2.0.0'
end

group :production do
  gem 'pg'
end

Gem をインストール

terminal
bundle install

Model を作る

フィールドは2つだけです

  1. title: タスクの内容
  2. completed: 完了・未完了
terminal
rails g model Todo title:string completed:boolean

migration ファイルの修正

Not Null 制約 と デフォルト値を追記してます

db/migrate/20190525063511_create_tasks.rb
class CreateTodos < ActiveRecord::Migration[5.2]
  def change
    create_table :todos do |t|
      t.string :title, null: false
      t.boolean :completed, default: false, null: false

      t.timestamps
    end
  end
end

マイグレーション

terminal
rails g model Task title:string completed:boolean

Model にバリデーションを追加

app/models/todo.rb
class Todo < ApplicationRecord
  validates :title, presence: true
end

ルーティングの修正

resources :todos, except: :show
以外に2つルーティングを追加しました

  1. patch 'check_all', to: 'todos#check_all': タスクの完了・未完了
  2. delete 'delete_completed', to: 'todos#delete_completed': 完了タスクを全削除
config/routes.rb
Rails.application.routes.draw do
  root 'api/v1/todos#index'

  namespace :api do
    namespace :v1, format: :json do
      patch 'check_all', to: 'todos#check_all'
      delete 'delete_completed', to: 'todos#delete_completed'
      resources :todos, except: :show
    end
  end
end

ルーティングは詳細は、こんな感じです

terminal
                   Prefix Verb   URI Pattern                                                                              Controller#Action
                     root GET    /                                                                                        api/v1/todos#index
         api_v1_check_all PATCH  /api/v1/check_all(.:format)                                                              api/v1/todos#check_all
  api_v1_delete_completed DELETE /api/v1/delete_completed(.:format)                                                       api/v1/todos#delete_completed
             api_v1_todos GET    /api/v1/todos(.:format)                                                                  api/v1/todos#index
                          POST   /api/v1/todos(.:format)                                                                  api/v1/todos#create
              api_v1_todo PATCH  /api/v1/todos/:id(.:format)                                                              api/v1/todos#update
                          PUT    /api/v1/todos/:id(.:format)                                                              api/v1/todos#update
                          DELETE /api/v1/todos/:id(.:format)                                                              api/v1/todos#destroy
       rails_service_blob GET    /rails/active_storage/blobs/:signed_id/*filename(.:format)                               active_storage/blobs#show
rails_blob_representation GET    /rails/active_storage/representations/:signed_blob_id/:variation_key/*filename(.:format) active_storage/representations#show
       rails_disk_service GET    /rails/active_storage/disk/:encoded_key/*filename(.:format)                              active_storage/disk#show
update_rails_disk_service PUT    /rails/active_storage/disk/:encoded_token(.:format)                                      active_storage/disk#update
     rails_direct_uploads POST   /rails/active_storage/direct_uploads(.:format)                                           active_storage/direct_uploads#create
```

### controller の用意

```bash:terminal
rails g controller api::v1::todos

controller を修正

app/controllers/api/v1/todos_controller.rb
class Api::V1::TodosController < ApplicationController
  before_action :set_todo, only: %i[show update destroy]

  # GET api/vi/todos/
  def index
    @todos = Todo.all.order(created_at: :asc)
    render json: @todos
  end

  # Post api/vi/todos
  def create
    @todo = Todo.new(todo_params)
    if @todo.save
      render json: @todo
    else
      render json: { status: 'error', data: @todo.errors }
    end
  end

  # Put api/vi/todos/:id
  def update
    if @todo.update(todo_params)
      render json: @todo
    else
      render json: { status: 'error', data: @todo.errors }
    end
  end

  # Delete api/vi/todos/:id
  def destroy
    @todo.destroy
    render json: @todo
  end

  # Delete api/vi/delete_completed
  def delete_completed
    todo = Todo.where(completed: true).delete_all
    render json: todo
  end

  # Put api/vi/check_all
  def check_all
    todo = Todo.update_all(completed: params['checked'])
    render json: todo
  end

  private

  def todo_params
    params.require(:todo).permit(:title, :completed)
  end

  def set_todo
    @todo = Todo.find(params[:id])
  end
end

cors の設定ファイルを修正

Vue 側からのアクセスを許可するため追記

origins 'http://localhost:8080'
config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins 'http://localhost:8080'

    resource '*',
             headers: :any,
             methods: %i[get post put patch delete options head]
  end
end

seed ファイルを修正

テストデータ作成用

db/seeds.rb
10.times do |i|
  Todo.create(title: "title No#{i + 1}", completed: i.even?)
end

postman で確認してみる

terminal
rails s
rails db:seed

Postman をインストールされていない方は、こちらからインストールしてください

  1. プルダウンから GET を選択し http://localhost:3000/api/v1/todos を入力
  2. Send をクリック
  3. テストデータの json が返ってくることを確認

時間がある方はその他のアクションも試してみてください
やり方は、ここでは割愛します

Screen Shot 2019-05-25 at 20.54.02.png

【Vue】フロントエンドの作成

プロジェクトを作成

Rails プロジェクトの直下に frontend という名前で Vue プロジェクトを作成します

terminal
vue create frontend

いくつか質問がでてくるので

  • Manually select features を選択肢し
  • Vuex を追加してください

その他はお好みでどうぞ

terminal
? Please pick a preset:
default (babel, eslint)
❯ Manually select features
terminal
? Check the features needed for your project:
 ◉ Babel
 ◯ TypeScript
 ◯ Progressive Web App (PWA) Support
 ◯ Router
❯◉ Vuex
 ◯ CSS Pre-processors
 ◉ Linter / Formatter
 ◯ Unit Testing
 ◯ E2E Testing

インストールが完了したら fronend へ移動しサーバを起動してみましょう

terminal
cd frontend && yarn serve

ブラウザから http://localhost:8080/ へアクセスし
こんな画面が表示されたら成功です。

Screen Shot 2019-05-25 at 16.03.28.png

Bootstrap と axios を追加

  • BootstrapVue: Vue 用の Bootstrap モジュール
  • axios: 今時の ajax モジュール
terminal
yarn add bootstrap-vue bootstrap axios

bootstrap の設定を追加

bootstrap を使うため main.js に追記

import BootstrapVue from "bootstrap-vue";
import "bootstrap/dist/css/bootstrap.css";
import "bootstrap-vue/dist/bootstrap-vue.css";

Vue.use(BootstrapVue);
frontend/src/main.js
import Vue from "vue";
import App from "./App.vue";
import store from "./store";
import BootstrapVue from "bootstrap-vue";
import "bootstrap/dist/css/bootstrap.css";
import "bootstrap-vue/dist/bootstrap-vue.css";

Vue.use(BootstrapVue);
Vue.config.productionTip = false;

new Vue({
  store,
  render: h => h(App)
}).$mount("#app");

store を編集

frontend/src/store.js
import Vue from "vue";
import Vuex from "vuex";
import axios from "axios";

Vue.use(Vuex);

const http = axios.create({
  baseURL:
    process.env.NODE_ENV === "development" ? "http://localhost:3000/" : "/",
  headers: {
    "Content-Type": "application/json",
    "X-Requested-With": "XMLHttpRequest"
  },
  responseType: "json"
});

export default new Vuex.Store({
  state: {
    filter: "all",
    todos: []
  },
  getters: {
    remaining(state) {
      return state.todos.filter(todo => !todo.completed).length;
    },
    completedAll(state, getters) {
      return getters.remaining === 0;
    },
    todosFiltered(state) {
      if (state.filter === "all") {
        return state.todos;
      } else if (state.filter === "active") {
        return state.todos.filter(todo => !todo.completed);
      } else if (state.filter === "completed") {
        return state.todos.filter(todo => todo.completed);
      }
      return state.todos;
    },
    showClearCompletedButton(state) {
      return state.todos.filter(todo => todo.completed).length > 0;
    }
  },
  mutations: {
    addTodo(state, todo) {
      state.todos.push({
        id: todo.id,
        title: todo.title,
        completed: false,
        editing: false
      });
    },
    clearCompleted(state) {
      state.todos = state.todos.filter(todo => !todo.completed);
    },
    updateFilter(state, filter) {
      state.filter = filter;
    },
    checkAll(state, checked) {
      state.todos.forEach(todo => {
        todo.completed = checked;
      });
    },
    deleteTodo(state, id) {
      const index = state.todos.findIndex(todo => todo.id === id);
      state.todos.splice(index, 1);
    },
    updateTodo(state, todo) {
      const index = state.todos.findIndex(item => item.id === todo.id);

      state.todos.splice(index, 1, {
        id: todo.id,
        title: todo.title,
        completed: todo.completed,
        editing: todo.editing
      });
    },
    retrieveTodos(state, todos) {
      state.todos = todos;
    }
  },
  actions: {
    retrieveTodos({ commit }) {
      http
        .get("/api/v1/todos")
        .then(response => {
          commit("retrieveTodos", response.data);
        })
        .catch(error => {
          console.log(error);
        });
    },
    addTodo({ commit }, todo) {
      http
        .post("/api/v1/todos", {
          title: todo.title,
          completed: false
        })
        .then(response => {
          commit("addTodo", response.data);
        })
        .catch(error => {
          console.log(error);
        });
    },
    clearCompleted({ commit }) {
      http
        .delete("/api/v1/delete_completed")
        .then(response => {
          commit("clearCompleted", response.data);
        })
        .catch(error => {
          console.log(error);
        });
    },
    checkAll({ commit }, checked) {
      http
        .patch("/api/v1/check_all", {
          checked
        })
        .then(() => {
          commit("checkAll", checked);
        })
        .catch(error => {
          console.log(error);
        });
    },
    deleteTodo({ commit }, id) {
      http
        .delete(`/api/v1/todos/${id}`)
        .then(response => {
          commit("deleteTodo", response.data.id);
        })
        .catch(error => {
          console.log(error);
        });
    },
    updateTodo({ commit }, todo) {
      http
        .patch(`/api/v1/todos/${todo.id}`, {
          title: todo.title,
          completed: todo.completed
        })
        .then(response => {
          commit("updateTodo", response.data);
        })
        .catch(error => {
          console.log(error);
        });
    }
  }
});

store は大きく5つのブロックに分かれています

axios のデフォルト通信設定

  • baseURL: API 取得のための URL
  • header:リクエスト時のヘッダの値
  • responseType:レスポンスの形式
import axios from "axios";

Vue.use(Vuex);

const http = axios.create({
  baseURL:
    process.env.NODE_ENV === "development" ? "http://localhost:3000/" : "/",
  headers: {
    "Content-Type": "application/json",
    "X-Requested-With": "XMLHttpRequest"
  },
  responseType: "json"
});

state

アプリの状態管理をするための単一オブジェクトです

ステート | Vuex

  • todo: タスクの配列
  • filter:全て ・ 完了 ・ 未完了 のフィルタ
state: {
  filter: "all",
  todos: []
},

getters

component でいう computed にあたります

ゲッター | Vuex

  • remaining: タスク完了の件数
  • showClearCompletedButton: クリアボタンを 表示 ・ 非表示 の切替用
getters: {
    remaining(state) {
      return state.todos.filter(todo => !todo.completed).length;
    },

    /*************** 省略 ***************/

    showClearCompletedButton(state) {
      return state.todos.filter(todo => todo.completed).length > 0;
    }
}

mutaition

state を変更するためのメソッド群です
action を経由して state を更新するために使っています

ミューテーション | Vuex

  • addTodo: 新しいタスクを追加しています
  • retrieveTodos: ページに最初にアクセスしたとき、タスク一覧を作成しています
mutations: {
  addTodo(state, todo) {
    state.todos.push({
      id: todo.id,
      title: todo.title,
      completed: false,
      editing: false
    });
  },

  /*************** 省略 ***************/

  retrieveTodos(state, todos) {
    state.todos = todos;
  }
},

actions

非同期処理を行うためのメソッド群です
axios を使って Rails API を取得するために使っています

アクション | Vuex

  • retrieveTodos: Rails の API からタスク一覧を取得しています
  • updateTodo: Rails の API からタスクの更新結果を取得しています
 actions: {
  retrieveTodos({ commit }) {
    http
      .get("/api/v1/todos")
      .then(response => {
        commit("retrieveTodos", response.data);
      })
      .catch(error => {
        console.log(error);
      });
  },

  /*************** 省略 ***************/

  updateTodo({ commit }, todo) {
    http
      .patch(`/api/v1/todos/${todo.id}`, {
        title: todo.title,
        completed: todo.completed
      })
      .then(response => {
        commit("updateTodo", response.data);
      })
      .catch(error => {
        console.log(error);
      });
  }
}

App を編集

frontend/src/App.vue
<template>
  <div id="app" class="container">
    <img alt="Vue logo" src="./assets/logo.png" class="logo" />
    <h1>VueTODO</h1>
    <todo-list></todo-list>
  </div>
</template>

<script>
import TodoList from "./components/TodoList.vue";

export default {
  name: "App",
  components: {
    TodoList
  }
};
</script>

<style lang="scss" scoped>
.logo {
  margin: 0 auto;
  display: block;
}
</style>

メインとなる TodoList コンポーネントを呼び出しています

import TodoList from "./components/TodoList.vue";

export default {
  name: "App",
  components: {
    TodoList
  }
};

TodoList コンポーネントを作成

新しいタスクの追加 と 子コンポーネントを束ねています

frontend/src/components/TodoList.vue
<template>
  <div>
    <b-container class="bv-example-row">
      <b-row>
        <b-col cols="12">
          <b-form @submit.prevent="addTodo">
            <b-form-group label="New todo" label-for="new-todo">
              <b-form-input
                id="new-todo"
                v-model="newTodo"
                placeholder="What needs to be done?"
              ></b-form-input>
            </b-form-group>
          </b-form>

          <b-list-group>
            <transition-group name="fade">
              <TodoItem
                v-for="(todo, index) in todosFiltered"
                :key="todo.id"
                :todo="todo"
                :index="index"
                class="todo-item"
                :check-all="completedAll"
              />
            </transition-group>
          </b-list-group>

          <b-list-group class="mt-4">
            <b-list-group-item
              class="flex-wrap d-flex justify-content-around align-items-center"
            >
              <TodoCheckAll />
              <TodoItemsRemaining />
            </b-list-group-item>
            <b-list-group-item
              class="flex-wrap d-flex justify-content-around align-items-center"
            >
              <TodoFiltered />
              <TodoClearCompleted />
            </b-list-group-item>
          </b-list-group>
        </b-col>
      </b-row>
    </b-container>
  </div>
</template>

<script>
import TodoItem from "@/components/TodoItem";
import TodoItemsRemaining from "@/components/TodoItemsRemaining";
import TodoCheckAll from "@/components/TodoCheckAll";
import TodoFiltered from "@/components/TodoFiltered";
import TodoClearCompleted from "@/components/TodoClearCompleted";
import { mapGetters } from "vuex";

export default {
  name: "TodoList",
  components: {
    TodoItem,
    TodoItemsRemaining,
    TodoCheckAll,
    TodoFiltered,
    TodoClearCompleted
  },
  data() {
    return {
      newTodo: ""
    };
  },
  computed: {
    ...mapGetters(["completedAll", "todosFiltered"])
  },
  created() {
    this.$store.dispatch("retrieveTodos");
  },
  methods: {
    addTodo() {
      if (this.newTodo.trim()) {
        this.$store.dispatch("addTodo", {
          id: this.idForTodo,
          title: this.newTodo
        });
      }
      this.newTodo = "";
    }
  }
};
</script>

<style lang="scss">
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.5s;
}
.fade-enter,
.fade-leave-to {
  opacity: 0;
}
</style>

TodoItem コンポーネントを作成

親コンポーネントの TodoList から props を受け取り
個々のタスクの表示させています

frontend/src/components/TodoItem.vue
<template>
  <b-list-group-item
    class="flex-wrap d-flex justify-content-around align-items-center todo-item"
  >
    <b-col cols="2">
      <b-form-checkbox v-model="completed" @input="doneEdit"></b-form-checkbox>
    </b-col>

    <b-col cols="8">
      <label
        v-if="!editing"
        :class="{ completed: completed }"
        @dblclick="editing = true"
        >{{ title }}</label
      >
      <b-form-input
        v-else
        v-model="title"
        v-focus
        type="text"
        @blur="doneEdit"
        @keyup.enter="doneEdit"
        @keyup.escape="cancelEdit"
      />
    </b-col>

    <b-col cols="2">
      <button
        type="button"
        class="close"
        aria-label="Close"
        @click="deleteTodo(todo.id)"
      >
        <span aria-hidden="true">&times;</span>
      </button>
    </b-col>
  </b-list-group-item>
</template>
<script>
import { mapActions } from "vuex";
export default {
  name: "TodoItem",
  directives: {
    focus: {
      inserted: function(el) {
        el.focus();
      }
    }
  },
  props: {
    todo: {
      type: Object,
      required: true
    },
    index: {
      type: Number,
      required: true
    },
    checkAll: {
      type: Boolean,
      required: true
    }
  },
  data() {
    return {
      id: this.todo.id,
      title: this.todo.title,
      completed: this.todo.completed,
      editing: false
    };
  },
  watch: {
    checkAll() {
      this.completed = this.checkAll ? true : this.todo.completed;
    }
  },
  methods: {
    ...mapActions(["deleteTodo", "updateTodo"]),
    doneEdit() {
      this.editing = false;

      this.updateTodo({
        id: this.id,
        title: this.title,
        completed: this.completed,
        editing: this.editing
      });
    },
    cancelEdit() {
      this.title = this.todo.title;
      this.editing = false;
    }
  }
};
</script>

<style lang="scss" scoped>
.todo-item {
  animation-duration: 0.3s;
}

.completed {
  text-decoration: line-through;
  color: grey;
}
</style>

TodoItemsRemaining コンポーネントを作成

残りのタスク件数を表示させています

frontend/src/components/TodoItemsRemaining.vue
<template>
  <b-col cols="6">
    <span class="text-danger">{{ remaining }}</span>
    {{ remaining | pluralize("item") }} left
  </b-col>
</template>

<script>
import { mapGetters } from "vuex";
export default {
  name: "TodoItemsRemaining",
  filters: {
    pluralize: (n, w) => (n === 1 ? w : w + "s")
  },
  computed: {
    ...mapGetters(["remaining"])
  }
};
</script>

TodoFiltered コンポーネントを作成

All(全て) ・ Active(未完了) ・ Completed(完了)
の値によってタスクにフィルタをかけています

frontend/src/components/TodoFiltered.vue
<template>
  <b-col cols="6">
    <b-form-radio-group
      v-model="selected"
      :options="options"
      buttons
      button-variant="outline-primary"
      name="radio-btn-outline"
      @change="updateFilter"
    ></b-form-radio-group>
  </b-col>
</template>

<script>
import { mapState, mapMutations } from "vuex";
export default {
  name: "TodoFiltered",
  data() {
    return {
      selected: "all",
      options: [
        { text: "All", value: "all" },
        { text: "Active", value: "active" },
        { text: "Completed", value: "completed" }
      ]
    };
  },
  computed: {
    ...mapState(["filter"])
  },
  methods: {
    ...mapMutations(["updateFilter"])
  }
};
</script>

TodoClearCompleted コンポーネントを作成

完了したタスクの一括クリアボタンを表示させています

frontend/src/components/TodoClearCompleted.vue
<template>
  <b-col cols="6">
    <div>
      <b-button
        v-if="showClearCompletedButton"
        variant="outline-primary"
        @click="clearCompleted"
        >Clear Completed</b-button
      >
    </div>
  </b-col>
</template>

<script>
import { mapGetters, mapActions } from "vuex";
export default {
  name: "TodoClearCompleted",
  computed: {
    ...mapGetters(["showClearCompletedButton"])
  },
  methods: {
    ...mapActions(["clearCompleted"])
  }
};
</script>

TodoCheckAll コンポーネントを作成

タスクを一括で完了 ・ 未完了に切り替えるための
チェックボックスを表示させています

frontend/src/components/TodoCheckAll.vue
<template>
  <b-col cols="6">
    <b-form-checkbox :checked="completedAll" @change="checkAll"
      >Check All</b-form-checkbox
    >
  </b-col>
</template>

<script>
import { mapGetters, mapActions } from "vuex";
export default {
  name: "TodoCheckAll",
  computed: {
    ...mapGetters(["completedAll"])
  },
  methods: {
    ...mapActions(["checkAll"])
  }
};
</script>

ブラウザで確認

Rail のサーバを起動

terminal
rails s

Vue のサーバを起動

terminal
cd frontend
yarn serve

localhost:8080 にアクセスしてこんな画面が表示されたら成功です

Screen Shot 2019-05-27 at 11.00.41.png

Heroku へデプロイしてみる

事前準備

Heroku のアカウントがない場合はこちらから作成してください

デプロイには heroku toolbelt が必要なのでこちらからインストールしてください

mac の場合は Homebrew でインストール可能です

terminal
brew install heroku

プロジェクトを commit

プロジェクト直下へ移動し commit を行なってください

terminal
git init
git add .
git commit -m "init"

vue.config.js を作成

frontend ディレクトリの直下に vue.config.js ファイルを作成し
build ファイルの出力先をプロジェクト直下の public ディレクトリへ変更します

frontend/vue.config.js
module.exports = {
  outputDir: "../public"
};

Vue を build

terminal
yarn build

プロジェクト直下の public ディレクトリに
build されたファイルが作成されていることを確認してください

terminal
public/
├── css
│   ├── app.27d4506b.css
│   └── chunk-vendors.19588e8d.css
├── favicon.ico
├── img
│   └── logo.82b9c7a5.png
├── index.html
└── js
    ├── app.6635e2d3.js
    ├── app.6635e2d3.js.map
    ├── chunk-vendors.4ad97586.js
    └── chunk-vendors.4ad97586.js.map

Heroku にログイン

terminal
heroku login

上のコマンドを実行するとブラウザに切替わるのでボタンを押してログインしてください

Screen Shot 2019-05-27 at 11.15.45.png

Heroku にアプリを作成

アプリ名を入力すると URL にアプリ名が反映されます
https://アプリ名.herokuapp.com/
省略すると Heroku 側で自動的に割り振られます

terminal
heroku create アプリ名

Heroku のリポジトリへ push

terminal
git push heroku master

データベースの migration と テストデータを追加

terminal
heroku run rails db:migrate
heroku run rails db:seed

ブラウザで確認

terminal
heroku open

おわりに

最後まで読んでいただきありがとうございました。
おかしな部分がありましたら、ご指摘お願いします。

7
8
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
7
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?